diff --git a/-a b/-a new file mode 100644 index 0000000..34b9e9d --- /dev/null +++ b/-a @@ -0,0 +1,3 @@ + +#omz revision: d2b7f557cceaddb16ba89bbf8200d8a8df35c4d8 +#omz fpath: /home/zach/.oh-my-zsh/plugins/colored-man-pages /home/zach/.oh-my-zsh/plugins/universalarchive /home/zach/.oh-my-zsh/plugins/tldr /home/zach/.oh-my-zsh/plugins/extract /home/zach/.oh-my-zsh/plugins/copypath /home/zach/.oh-my-zsh/plugins/cp /home/zach/.oh-my-zsh/plugins/conda-env /home/zach/.oh-my-zsh/plugins/command-not-found /home/zach/.oh-my-zsh/plugins/dirhistory /home/zach/.oh-my-zsh/plugins/copybuffer /home/zach/.oh-my-zsh/plugins/copyfile /home/zach/.oh-my-zsh/custom/plugins/fast-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-autosuggestions /home/zach/.oh-my-zsh/plugins/archlinux /home/zach/.oh-my-zsh/plugins/web-search /home/zach/.oh-my-zsh/plugins/sudo /home/zach/.oh-my-zsh/plugins/git /home/zach/.oh-my-zsh/functions /home/zach/.oh-my-zsh/completions /home/zach/.oh-my-zsh/custom/functions /home/zach/.oh-my-zsh/custom/completions /home/zach/tools/ripgrep/complete /home/linuxbrew/.linuxbrew/share/zsh/site-functions /home/zach/.oh-my-zsh/plugins/colored-man-pages /home/zach/.oh-my-zsh/plugins/universalarchive /home/zach/.oh-my-zsh/plugins/tldr /home/zach/.oh-my-zsh/plugins/extract /home/zach/.oh-my-zsh/plugins/copypath /home/zach/.oh-my-zsh/plugins/cp /home/zach/.oh-my-zsh/plugins/conda-env /home/zach/.oh-my-zsh/plugins/command-not-found /home/zach/.oh-my-zsh/plugins/dirhistory /home/zach/.oh-my-zsh/plugins/copybuffer /home/zach/.oh-my-zsh/plugins/copyfile /home/zach/.oh-my-zsh/custom/plugins/fast-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-autosuggestions /home/zach/.oh-my-zsh/plugins/archlinux /home/zach/.oh-my-zsh/plugins/web-search /home/zach/.oh-my-zsh/plugins/sudo /home/zach/.oh-my-zsh/plugins/git /home/zach/.oh-my-zsh/functions /home/zach/.oh-my-zsh/completions /home/zach/.oh-my-zsh/custom/functions /home/zach/.oh-my-zsh/custom/completions /home/zach/.oh-my-zsh/cache/completions /home/zach/tools/ripgrep/complete /home/linuxbrew/.linuxbrew/share/zsh/site-functions /usr/local/share/zsh/site-functions /usr/share/zsh/site-functions /usr/share/zsh/functions/Calendar /usr/share/zsh/functions/Chpwd /usr/share/zsh/functions/Completion /usr/share/zsh/functions/Completion/Base /usr/share/zsh/functions/Completion/Linux /usr/share/zsh/functions/Completion/Unix /usr/share/zsh/functions/Completion/X /usr/share/zsh/functions/Completion/Zsh /usr/share/zsh/functions/Exceptions /usr/share/zsh/functions/MIME /usr/share/zsh/functions/Math /usr/share/zsh/functions/Misc /usr/share/zsh/functions/Newuser /usr/share/zsh/functions/Prompts /usr/share/zsh/functions/TCP /usr/share/zsh/functions/VCS_Info /usr/share/zsh/functions/VCS_Info/Backends /usr/share/zsh/functions/Zftp /usr/share/zsh/functions/Zle /home/zach/.zfunc diff --git a/Components/ClipRect.qml b/Components/ClipRect.qml index 39ac012..48deed0 100644 --- a/Components/ClipRect.qml +++ b/Components/ClipRect.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import ZShell.Components Item { @@ -19,7 +20,7 @@ Item { } ShaderEffectSource { - id: source + id: src hideSource: true sourceItem: contentItem @@ -27,7 +28,7 @@ Item { RoundedMask { anchors.fill: parent - source: source + source: src topLeftRadius: root.topLeftRadius topRightRadius: root.topRightRadius bottomRightRadius: root.bottomRightRadius diff --git a/Components/Menu.qml b/Components/Menu.qml index 6a3db28..441b4eb 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -34,7 +34,7 @@ MouseArea { cursorShape: expanded ? Qt.ArrowCursor : undefined enabled: expanded hoverEnabled: expanded - layer.enabled: opacity < 1 + layer.enabled: false opacity: expanded ? 1 : 0 parent: { const win = QsWindow.window; @@ -101,8 +101,7 @@ MouseArea { yScale: root.expanded ? 1 : 0.1 Behavior on yScale { - Anim { - } + Anim {} } } @@ -137,8 +136,7 @@ MouseArea { radius: menu.radius - column.anchors.margins Behavior on radius { - Anim { - } + Anim {} } StateLayer { diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index c335feb..9a0717e 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Io import QtQuick import ZShell.Models -import ZShell.Internal +import ZShell.Wallpaper import ZShell.Config import qs.Components import qs.Services @@ -16,24 +16,48 @@ Searcher { id: root property string actualCurrent: WallpaperPath.currentWallpaperPath - property alias crops: adapter.monitorCrops readonly property string current: showPreview ? previewPath : actualCurrent - property alias monitorCrops: monitorCrops + readonly property string currentThumbnail: thumbnailFor(current) + readonly property bool currentIsVideo: isVideoPath(current) property bool pendingPreviewClear property bool previewColorLock property string previewPath property bool recentlyChanged property bool showPreview: false + property int cropVersion: 0 - function getCategoryFor(w: FileSystemEntry): string { + function getCategoryFor(w: WallpaperEntry): string { let category = w.parentDir.slice(Paths.wallsdir.length + 1); if (category.includes("/")) category = category.slice(0, category.indexOf("/")); return category; } + readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"] + + function isVideoPath(path: string): bool { + if (!path) + return false; + const dot = path.lastIndexOf("."); + if (dot < 0) + return false; + return videoExts.includes(path.slice(dot + 1).toLowerCase()); + } + + function thumbnailFor(path: string): string { + if (!path) + return ""; + + for (const entry of root.list) { + if (entry.path === path) + return entry.thumbnailPath || path; + } + + return path; + } + function getCrop(screen: string): var { - return root.crops[screen]; + return wallpapers.crop(root.current, screen); } function preview(path: string): void { @@ -50,19 +74,18 @@ Searcher { else if (zoom > 5.0) zoom = 5.0; - root.crops[screen] = { + wallpapers.setCrop(root.current, screen, { x: rect.x, y: rect.y, width: rect.width, height: rect.height, zoom: zoom - }; + }); } function setWallpaper(path: string): void { actualCurrent = path; WallpaperPath.currentWallpaperPath = path; - Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); if (Config.colors.schemeGen) Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); @@ -96,38 +119,13 @@ Searcher { target: "wallpaper" } - FileView { - id: monitorCrops - - path: `${Paths.state}/wallpaper-crops.json` - watchChanges: true - - onAdapterUpdated: cropWriteDelay.restart() - onFileChanged: reload() - - JsonAdapter { - id: adapter - - property var monitorCrops: ({}) - } - } - - Timer { - id: cropWriteDelay - - interval: 100 - repeat: false - running: false - - onTriggered: monitorCrops.writeAdapter() - } - - FileSystemModel { + WallpaperDb { id: wallpapers - filter: FileSystemModel.Images + dbPath: `${Paths.state}/wallpapers.sqlite` + cacheSize: wallpaper.largestBufferSize + requireHardwareCodec: true path: Config.general.wallpaperPath - recursive: true } Process { @@ -146,36 +144,100 @@ Searcher { WallpaperService { id: wallpaper + dbPath: wallpapers.dbPath path: root.current } + Connections { + target: wallpapers + + function onCacheStored(path: string): void { + if (path === root.current) + wallpaper.invalidateCache(); + } + + function onCropsChanged(path: string): void { + if (path === root.current) + root.cropVersion++; + } + } + Variants { + id: screenVariants model: Quickshell.screens WallpaperObject { - readonly property var crops: root.getCrop(modelData.name) + id: obj required property var modelData - cropHeight: crops?.height ?? 1.0 - cropWidth: crops?.width ?? 1.0 - cropX: crops?.x ?? 0.0 - cropY: crops?.y ?? 0.0 + // Crop animation only for edits, never for wallpaper swaps. + property bool animateCrop: false + screen: modelData.name service: wallpaper - zoom: crops?.zoom ?? 1.0 + + // Apply the wallpaper's own crop. Driven off the service's pathChanged + // (which fires after applyPath has snapshotted the outgoing frame with + // the outgoing crop) and off crop edits, never as a reactive binding on + // the wallpaper path — so a swap applies the incoming crop atomically + // after the base is captured. + function seedCrop(): void { + // Load the current wallpaper's crop without pushing: the service + // already read it from the database. This only seeds the values so + // a later edit animates from the right starting point. + obj.syncCrop(root.getCrop(modelData.name)); + } + + // Apply an edited crop: assign via the setters so the Behaviors animate + // the numbers and push each intermediate value to the service. + function editCrop(): void { + const c = root.getCrop(modelData.name); + obj.cropX = c?.x ?? 0.0; + obj.cropY = c?.y ?? 0.0; + obj.cropWidth = c?.width ?? 1.0; + obj.cropHeight = c?.height ?? 1.0; + obj.zoom = c?.zoom ?? 1.0; + } + + Component.onCompleted: seedCrop() + onScreenChanged: seedCrop() Behavior on cropHeight { + enabled: obj.animateCrop Anim {} } Behavior on cropWidth { + enabled: obj.animateCrop Anim {} } Behavior on cropX { + enabled: obj.animateCrop Anim {} } Behavior on cropY { + enabled: obj.animateCrop Anim {} } } } + + Connections { + target: wallpaper + function onPathChanged(): void { + for (const obj of screenVariants.instances) { + obj.animateCrop = false; + obj.seedCrop(); + } + } + } + + Connections { + target: root + function onCropVersionChanged(): void { + for (const obj of screenVariants.instances) { + obj.animateCrop = true; + obj.editCrop(); + } + } + } } diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 856b391..07ef70c 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -210,8 +210,7 @@ GridLayout { roleValue: "network" delegate: EntryWrapper { - NetworkWidget { - } + NetworkWidget {} } } diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e47f198..9d3c4a8 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -45,7 +45,9 @@ WidgetBase { } required property GridLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 + readonly property int roundedWidth: Math.round(timeText.implicitWidth) + readonly property int evenWidth: roundedWidth % 2 === 0 ? roundedWidth : roundedWidth + 1 + readonly property real size: horizontal ? evenWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property ScreenState screenState implicitHeight: horizontal ? baseSize : size diff --git a/Modules/Bar/Components/Common/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml index 6a78dfa..01606b2 100644 --- a/Modules/Bar/Components/Common/BatteryIcon.qml +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -24,7 +24,7 @@ Row { color: Battery.getColors(root.percentage, root.devState).bg height: root.batHeight radius: root.radius - width: root.batWidth + width: root.batWidth - 1 CustomText { color: Battery.getColors(root.percentage, root.devState).fg diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 7d2297f..542dc0a 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -13,7 +13,7 @@ WidgetBase { readonly property int textWidth: Math.min(metrics.width, 200) implicitHeight: horizontal ? baseSize : layout.implicitHeight + Tokens.padding.small * 2 - implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.small * 2 : baseSize + implicitWidth: horizontal ? (layout.implicitWidth % 2 === 0 ? layout.implicitWidth : layout.implicitWidth + 1) + Tokens.padding.small * 2 : baseSize radius: Tokens.rounding.full Behavior on implicitHeight { diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index b22d9e7..20b0816 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -12,7 +12,6 @@ GridLayout { property real animatedPercentage: 0 readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI - readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 required property bool horizontal property string icon required property color iconColor @@ -23,17 +22,12 @@ GridLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 - function snap(v) { - return Math.round(v * dpr) / dpr; - } - columnSpacing: Tokens.spacing.medium columns: horizontal ? -1 : 1 percentage: 0 Behavior on animatedPercentage { - Anim { - } + Anim {} } Component.onCompleted: animatedPercentage = percentage @@ -59,7 +53,7 @@ GridLayout { Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4 Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - radius: Math.min(width, height) / 2 + radius: Tokens.rounding.full Item { id: clipper @@ -69,18 +63,20 @@ GridLayout { anchors.left: root.horizontal ? undefined : parent.left anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter clip: true - height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2 - width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width) + implicitHeight: root.horizontal ? root.percentage * track.height : track.height * 2 + implicitWidth: root.horizontal ? track.width * 2 : root.percentage * track.width CustomRect { anchors.bottom: root.horizontal ? parent.bottom : undefined anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined anchors.left: root.horizontal ? undefined : parent.left anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + color: root.mainColor - height: track.height radius: track.radius - width: track.width + + implicitHeight: track.height + implicitWidth: track.width } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 32bd1f2..c22435d 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -33,6 +33,7 @@ WidgetBase { anchors.horizontalCenterOffset: root.horizontal ? 0 : 1 anchors.verticalCenterOffset: root.horizontal ? 0 : -3 columnSpacing: Tokens.spacing.small + rowSpacing: 0 columns: root.horizontal ? -1 : 1 ServiceRef { @@ -51,6 +52,7 @@ WidgetBase { Layout.alignment: Qt.AlignVCenter Layout.fillHeight: root.horizontal Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "memory" iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface diff --git a/Modules/Bar/Components/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml index b0c58af..43e1d30 100644 --- a/Modules/Bar/Components/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -31,7 +31,7 @@ WidgetBase { return -1; } readonly property real size: horizontal ? grid.implicitWidth + grid.anchors.leftMargin + grid.anchors.rightMargin : grid.implicitHeight + grid.anchors.bottomMargin + grid.anchors.topMargin - readonly property int spacing: Tokens.spacing.medium / 2 + readonly property int spacing: Tokens.spacing.extraSmall // Entries that can shrink to nothing, spacing included function collapsed(entry: var): bool { @@ -51,14 +51,12 @@ WidgetBase { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } GridLayout { @@ -70,6 +68,8 @@ WidgetBase { anchors.rightMargin: root.horizontal ? Tokens.padding.extraSmall : 0 anchors.topMargin: root.horizontal ? 0 : Tokens.padding.extraSmall columns: root.horizontal ? -1 : 1 + columnSpacing: root.spacing + rowSpacing: root.spacing Repeater { model: ScriptModel { @@ -111,8 +111,7 @@ WidgetBase { delegate: EntryWrapper { name: "upower" - UPowerWidget { - } + UPowerWidget {} } } } @@ -135,8 +134,8 @@ WidgetBase { Layout.rightMargin: root.horizontal ? Math.round(rightGap) : 0 Layout.topMargin: root.horizontal ? 0 : Math.round(leftGap) children: item - implicitHeight: item?.implicitHeight ?? 0 - implicitWidth: item?.implicitWidth ?? 0 + implicitHeight: item?.height ?? item?.implicitHeight ?? 0 + implicitWidth: item?.width ?? item?.implicitWidth ?? 0 Behavior on leftGap { Anim { diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index a6fab2a..d4fd37c 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -6,7 +6,7 @@ import qs.Components import qs.Services MaterialIcon { - id: speaker + id: root required property bool horizontal @@ -15,6 +15,8 @@ MaterialIcon { fill: 1 fontStyle: Tokens.font.icon.small text: Audio.muted ? "volume_off" : "volume_up" + width: Math.round(implicitWidth) + height: Math.round(implicitHeight) Behavior on Layout.maximumWidth { Anim {} diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index cb61bee..e31d696 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -6,7 +6,7 @@ import qs.Components import qs.Services MaterialIcon { - id: mic + id: root required property bool horizontal @@ -15,6 +15,8 @@ MaterialIcon { fill: 1 fontStyle: Tokens.font.icon.small text: Audio.sourceMuted ? "mic_off" : "mic" + width: Math.round(implicitWidth) + height: Math.round(implicitHeight) Behavior on Layout.maximumWidth { Anim {} diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index fae91a7..242234f 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -38,6 +38,7 @@ WidgetBase { anchors.topMargin: root.horizontal ? 0 : Tokens.padding.extraSmall columns: root.horizontal ? -1 : 1 rowSpacing: -2 + columnSpacing: Tokens.spacing.extraSmall Repeater { id: repeater diff --git a/Modules/Bar/Components/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml index 9c5d448..09cebc5 100644 --- a/Modules/Bar/Components/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -12,13 +12,14 @@ import qs.Services Item { id: root - property real activeWorkspaceMargin: Math.ceil(Tokens.padding.medium / 4) + property real activeWorkspaceMargin: Tokens.padding.extraSmall / 2 + readonly property int spacing: Tokens.spacing.extraSmall / 2 readonly property int effectiveActiveWorkspaceId: monitor?.activeWorkspace?.id ?? 1 required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen readonly property real shortSize: Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) - readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 + readonly property real size: (workspaceButtonWidth * workspacesShown) + (spacing * Math.max(0, workspacesShown - 1)) + activeWorkspaceMargin * 2 property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown readonly property list workspaces: Hyprland.workspaces.values.filter(w => w.monitor === root.monitor) @@ -59,15 +60,17 @@ Item { anchors.fill: parent anchors.margins: root.activeWorkspaceMargin - columnSpacing: 0 + columnSpacing: root.spacing columns: root.horizontal ? root.workspacesShown : 1 - rowSpacing: 0 + rowSpacing: root.spacing rows: root.horizontal ? 1 : root.workspacesShown + layer.enabled: true + layer.mipmap: true Repeater { model: root.workspaces - Button { + CustomRect { id: button required property int index @@ -75,27 +78,19 @@ Item { implicitHeight: indicator.indicatorThickness implicitWidth: indicator.indicatorThickness - width: root.workspaceButtonWidth + radius: Tokens.rounding.full - background: Item { - id: workspaceButtonBackground - - implicitHeight: root.workspaceButtonWidth - implicitWidth: root.workspaceButtonWidth - - CustomText { - anchors.centerIn: parent - color: Colors.palette.m3onSecondaryContainer - elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter - text: button.modelData?.name ?? "" - verticalAlignment: Text.AlignVCenter - } + CustomText { + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: button.modelData?.name ?? "" } - onPressed: { - const ws = button.modelData.name; - Hyprland.dispatch(Hyprland.usingLua ? `hl.dsp.focus({ workspace= "${ws}"})` : `workspace ${ws}`); + StateLayer { + onPressed: { + const ws = button.modelData.name; + Hyprland.dispatch(Hyprland.usingLua ? `hl.dsp.focus({ workspace= "${ws}"})` : `workspace ${ws}`); + } } } } @@ -104,8 +99,10 @@ Item { CustomRect { id: indicator - property real indicatorLength: (Math.abs(idxPair.idx1 - idxPair.idx2) + 1) * root.workspaceButtonWidth - property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * root.workspaceButtonWidth + root.activeWorkspaceMargin + readonly property real step: root.workspaceButtonWidth + root.spacing + + property real indicatorLength: (Math.abs(idxPair.idx1 - idxPair.idx2) + 1) * root.workspaceButtonWidth + Math.abs(idxPair.idx1 - idxPair.idx2) * root.spacing + property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * step + root.activeWorkspaceMargin property real indicatorThickness: root.workspaceButtonWidth anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter @@ -129,9 +126,10 @@ Item { implicitHeight: grid.height implicitWidth: grid.width source: grid + autoPaddingEnabled: false sourceColor: Colors.palette.m3onSurface - x: root.horizontal ? -indicator.x + 3 : 0 - y: root.horizontal ? 0 : -indicator.y + 3 + x: root.horizontal ? -indicator.x + root.activeWorkspaceMargin : 0 + y: root.horizontal ? 0 : -indicator.y + root.activeWorkspaceMargin } } } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 8fd5c62..e287e86 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -54,7 +54,7 @@ Item { } } - ClipRect { + CustomRect { id: preview anchors.bottom: parent.bottom @@ -64,6 +64,7 @@ Item { color: Colors.tPalette.m3surfaceContainer implicitWidth: ClipHistory.previewIsImage ? Math.max(Math.min(imagePreview.sourceSize.width + Tokens.padding.large * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) : Math.max(Math.min(textPreviewColumn.width + textPreviewColumn.anchors.margins * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) radius: 25 + clip: true Behavior on implicitWidth { Anim {} @@ -228,11 +229,12 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - ClipRect { + Item { id: textRect Layout.fillHeight: true Layout.fillWidth: true + clip: true Item { id: textWrapper diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 1162fee..206cb16 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import Quickshell import QtQuick import ZShell.Config @@ -9,7 +11,7 @@ Item { id: root required property bool isCurrent - required property var modelData // FileSystemEntry-like {path, relativePath} + required property var modelData required property real tileHeight required property real tileWidth required property ScreenState screenState @@ -29,25 +31,75 @@ Item { radius: Tokens.rounding.small y: Tokens.padding.large - MaterialIcon { + Loader { + active: opacity > 0 anchors.centerIn: parent - color: Colors.tPalette.m3outline - fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() - text: "image" + opacity: img.status === Image.Ready ? 0 : 1 + + Behavior on opacity { + Anim {} + } + sourceComponent: CustomRect { + color: Colors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + radius: Tokens.rounding.full + + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(image.width, image.height) * 0.3 + } + } } - CachingImage { + Image { + id: img anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - cache: true + asynchronous: true fillMode: Image.PreserveAspectCrop height: root.tileHeight - path: root.modelData ? root.modelData.path : "" + source: root.modelData ? root.modelData.thumbnailPath : "" smooth: root.isCurrent sourceSize.height: root.tileHeight sourceSize.width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9) + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } StateLayer { diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index 1d8e734..0fd7c3b 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -5,14 +5,13 @@ import qs.Helpers import ZShell.Config import qs.Services -ClipRect { +Item { id: root required property var lock implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth - radius: Tokens.rounding.largeIncreased RowLayout { id: layout @@ -50,22 +49,10 @@ ClipRect { lock: root.lock } - ColumnLayout { + NotifDock { + Layout.fillHeight: true Layout.fillWidth: true - spacing: Tokens.spacing.medium - - CustomRect { - Layout.fillHeight: true - Layout.fillWidth: true - bottomRightRadius: Tokens.rounding.largeIncreased - color: Colors.tPalette.m3surfaceContainer - radius: Tokens.rounding.small - topRightRadius: Tokens.rounding.largeIncreased - - NotifDock { - lock: root.lock - } - } + lock: root.lock } } } diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index 540e704..22369bc 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -221,9 +221,9 @@ WlSessionLockSurface { anchors.fill: parent color: Colors.palette.m3surface - layer.enabled: true opacity: Colors.transparency.enabled ? Colors.transparency.base : 1 radius: lockContent.radius + layer.enabled: true layer.effect: MultiEffect { blurMax: 15 diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index 0d08f40..645101f 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -7,39 +7,44 @@ import qs.Components import qs.Helpers import qs.Services -ClipRect { +Item { id: root required property var lock - color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - radius: Tokens.rounding.small - - FadeImage { - id: image + ClipRect { anchors.fill: parent - asynchronous: true - fillMode: Image.PreserveAspectCrop - layer.enabled: true - opacity: status === Image.Ready ? 1 : 0 - source: Players.getArtUrl(Players.active) - sourceSize: { - const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; - return Qt.size(width * dpr, height * dpr); - } - Behavior on opacity { - Anim { - type: Anim.StandardExtraLarge - } - } + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.small + bottomLeftRadius: Tokens.rounding.largeIncreased + + FadeImage { + id: image - CustomRect { anchors.fill: parent - color: Colors.palette.m3surface - opacity: 0.7 + asynchronous: true + fillMode: Image.PreserveAspectCrop + opacity: status === Image.Ready ? 1 : 0 + source: Players.getArtUrl(Players.active) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + + Behavior on opacity { + Anim { + type: Anim.StandardExtraLarge + } + } + + CustomRect { + anchors.fill: parent + color: Colors.palette.m3surface + opacity: 0.7 + } } } @@ -77,7 +82,7 @@ ClipRect { spacing: Tokens.spacing.extraSmall IconButton { - enabled: Players.active?.canGoPrevious + enabled: Players.active?.canGoPrevious ?? false icon: "skip_previous" isRound: true shapeMorph: true @@ -88,7 +93,7 @@ ClipRect { IconButton { checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying + enabled: Players.active?.canTogglePlaying ?? false icon: Players.active?.isPlaying ? "pause" : "play_arrow" implicitWidth: implicitHeight + Tokens.padding.largeIncreased * 2 isRound: true @@ -98,7 +103,7 @@ ClipRect { } IconButton { - enabled: Players.active?.canGoNext + enabled: Players.active?.canGoNext ?? false icon: "skip_next" isRound: true shapeMorph: true diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index af8ed98..427ae8b 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -11,126 +11,116 @@ import ZShell.Config import qs.Daemons import qs.Services -ColumnLayout { +CustomRect { id: root required property var lock - anchors.fill: parent - anchors.margins: Tokens.padding.large - spacing: Tokens.spacing.small + bottomRightRadius: Tokens.rounding.largeIncreased + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.small + topRightRadius: Tokens.rounding.largeIncreased - CustomText { - Layout.fillWidth: true - color: Colors.palette.m3outline - elide: Text.ElideRight - font: Tokens.font.mono.builders.small.weight(Font.Medium).build() - text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") - } + ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small - ClipRect { - id: clipRect - - Layout.fillHeight: true - Layout.fillWidth: true - color: "transparent" - radius: Tokens.rounding.small - - Loader { - active: opacity > 0 - anchors.centerIn: parent - opacity: NotifServer.list.length > 0 ? 0 : 1 - - Behavior on opacity { - Anim { - duration: Tokens.anim.durations.extraLarge - } - } - sourceComponent: ColumnLayout { - spacing: Tokens.spacing.largeIncreased - - Image { - asynchronous: true - fillMode: Image.PreserveAspectFit - layer.enabled: true - source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) - sourceSize.width: clipRect.width * 0.8 - - layer.effect: Coloriser { - brightness: 1 - colorizationColor: Colors.palette.m3outlineVariant - } - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3outlineVariant - font: Tokens.font.headline.builders.small.width(90).build() - text: qsTr("No Notifications") - } - } + CustomText { + Layout.fillWidth: true + color: Colors.palette.m3outline + elide: Text.ElideRight + font: Tokens.font.mono.builders.small.weight(Font.Medium).build() + text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") } - CustomListView { - anchors.fill: parent - clip: true - spacing: Tokens.spacing.small + Item { + Layout.fillHeight: true + Layout.fillWidth: true - add: Transition { - Anim { - from: 0 - property: "opacity" - to: 1 + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: NotifServer.list.length > 0 ? 0 : 1 + + Behavior on opacity { + Anim { + duration: Tokens.anim.durations.extraLarge + } } + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.largeIncreased - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - from: 0 - property: "scale" - to: 1 + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3outlineVariant + font: Tokens.font.headline.builders.small.width(90).build() + text: qsTr("No Notifications") + } } } - delegate: NotifGroup {} - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - property: "y" - } - } - model: ScriptModel { - values: { - const list = NotifServer.notClosed.map(n => [n.appName, null]); - return [...new Map(list).keys()]; - } - } - move: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } + CustomListView { + anchors.fill: parent + clip: true + spacing: Tokens.spacing.small - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - property: "y" - } - } - remove: Transition { - Anim { - property: "opacity" - to: 0 - } + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } - Anim { - property: "scale" - to: 0.6 + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + from: 0 + property: "scale" + to: 1 + } + } + delegate: NotifGroup {} + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + property: "y" + } + } + model: ScriptModel { + values: { + const list = NotifServer.notClosed.map(n => [n.appName, null]); + return [...new Map(list).keys()]; + } + } + move: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + property: "y" + } + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + + Anim { + property: "scale" + to: 0.6 + } } } } diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index cd92521..57ae1b3 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -16,7 +16,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.title.large + font: Tokens.font.body.large text: Weather.description } @@ -29,7 +29,7 @@ ColumnLayout { animate: true color: Colors.palette.m3primary - font: Tokens.font.body.large + font: Tokens.font.title.large text: Weather.temp } @@ -37,6 +37,7 @@ ColumnLayout { animate: true color: Colors.palette.m3secondary text: Weather.icon + fontStyle: Tokens.font.icon.large } } @@ -53,7 +54,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.normal + font: Tokens.font.body.medium text: { const today = Weather.forecast[0]; return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index dd8cf82..263d90e 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -36,7 +36,7 @@ CustomRect { } } - VerticalFadeListView { + CustomListView { id: layout anchors.bottom: parent.bottom @@ -45,13 +45,20 @@ CustomRect { anchors.margins: Tokens.padding.large anchors.right: parent.right implicitHeight: contentItem.childrenRect.height - model: Weather.hourlyForecast + currentIndex: Weather.currentHourIndex + // highlightFollowsCurrentItem: true + // preferredHighlightEnd: currentItem.implicitWidth + // preferredHighlightBegin: 0 + // highlightRangeMode: ListView.ApplyRange + clip: true + model: Weather.hourlyForecast.slice(Weather.currentHourIndex - 1) orientation: VerticalFadeListView.Horizontal spacing: Tokens.spacing.medium delegate: ColumnLayout { id: hour + readonly property bool current: ListView.view.currentItem === this readonly property var cond: modelData required property int index required property var modelData @@ -60,7 +67,7 @@ CustomRect { MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.current ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided @@ -72,7 +79,7 @@ CustomRect { id: temp anchors.centerIn: parent - color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: hour.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font: Tokens.font.body.medium text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } @@ -96,7 +103,7 @@ CustomRect { Layout.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant font: Tokens.font.body.small - text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") + text: hour.current ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") } } } diff --git a/Modules/Lock/WeatherInfo.qml b/Modules/Lock/WeatherInfo.qml index 3de1b1f..feaec45 100644 --- a/Modules/Lock/WeatherInfo.qml +++ b/Modules/Lock/WeatherInfo.qml @@ -19,6 +19,7 @@ CustomRect { return base + brief.anchors.topMargin; } radius: Tokens.rounding.small + topLeftRadius: Tokens.rounding.largeIncreased Timer { interval: 900000 // 15 minutes @@ -48,7 +49,6 @@ CustomRect { anchors.right: parent.right asynchronous: true - sourceComponent: Forecast { - } + sourceComponent: Forecast {} } } diff --git a/Modules/Settings/Common/WallItem.qml b/Modules/Settings/Common/WallItem.qml index 84d15e5..c4f9c57 100644 --- a/Modules/Settings/Common/WallItem.qml +++ b/Modules/Settings/Common/WallItem.qml @@ -39,8 +39,7 @@ Item { opacity: img.status === Image.Ready ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primaryContainer @@ -64,18 +63,43 @@ Item { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectCrop - opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true sourceSize: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return Qt.size(width * dpr, height * dpr); } + opacity: status === Image.Ready ? 1 : 0 Behavior on opacity { Anim { type: Anim.SlowEffects } } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 966f6ee..aa0c5b6 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,9 +13,14 @@ import qs.Services Item { id: wrapper - property int buttonRowHeight: Tokens.padding.large * 3 + readonly property bool isVideo: Wallpapers.currentIsVideo + readonly property string currentWallpaper: Wallpapers.current + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 readonly property bool changesMade: { cropRevision; + Wallpapers.cropVersion; + wrapper.currentWallpaper; const r = cropRectLoader.item; if (!r || !currentScreen || scaledImg.status !== Image.Ready) @@ -25,9 +30,9 @@ Item { return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; } + + property int buttonRowHeight: Tokens.padding.large * 3 property int cropRevision: 0 - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null - readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -113,6 +118,9 @@ Item { refreshScreens(); Qt.callLater(syncCropToScreen); } + onCurrentWallpaperChanged: { + Qt.callLater(syncCropToScreen); + } onSelectedScreenIndexChanged: { Qt.callLater(syncCropToScreen); } @@ -127,7 +135,7 @@ Item { anchors.rightMargin: Tokens.padding.extraLarge * 2 anchors.top: parent.top - active: wrapper.screens.length > 1 + active: wrapper.screens.length > 1 && !wrapper.isVideo sourceComponent: ButtonRow { id: screenSelector @@ -163,6 +171,7 @@ Item { anchors.left: parent.left anchors.right: parent.right implicitHeight: 30 + enabled: !wrapper.isVideo CustomSlider { id: zoomSlider @@ -274,7 +283,7 @@ Item { opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true smooth: true - source: Wallpapers.current + source: Wallpapers.currentThumbnail sourceSize.height: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return imageSlot.height * dpr; @@ -285,12 +294,37 @@ Item { type: Anim.SlowEffects } } + + Loader { + opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } Loader { id: cropRectLoader - active: scaledImg.status === Image.Ready + active: scaledImg.status === Image.Ready && !wrapper.isVideo sourceComponent: Component { CustomRect { @@ -333,7 +367,6 @@ Item { y = t.y; } - // Where the rect should be according to saved data (same rules as a restore). function savedTarget() { const data = Wallpapers.getCrop(wrapper.currentScreen.name); const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); @@ -436,9 +469,10 @@ Item { } anchors.fill: parent - cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor hoverEnabled: true preventStealing: true + enabled: !wrapper.isVideo onPositionChanged: mouse => { if (pressed) { diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index a4d4906..f0b0abd 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -50,11 +50,12 @@ PageBase { } WallItem { - required property FileSystemEntry modelData + id: wallItem + required property WallpaperEntry modelData enabled: modelData opacity: modelData ? 1 : 0 - source: String(modelData?.path ?? "") + source: String(modelData?.thumbnailPath ?? "") onClicked: { Wallpapers.setWallpaper(modelData.path); diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index f3fcb6e..fe9747f 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -13,6 +13,42 @@ import qs.Services PageBase { id: root + readonly property list wallTransitions: [ + MenuItem { + text: "Bowtie" + }, + MenuItem { + text: "Fade" + }, + MenuItem { + text: "LinearBlur" + }, + MenuItem { + text: "WindowSlices" + }, + MenuItem { + text: "WipeRight" + }, + MenuItem { + text: "WipeDown" + }, + MenuItem { + text: "TilesWave" + }, + MenuItem { + text: "SplitSlideInHorizontal" + }, + MenuItem { + text: "Hexagonalize" + }, + MenuItem { + text: "DoomMelt" + }, + MenuItem { + text: "Random" + } + ] + title: qsTr("Wallpaper & style") ColumnLayout { @@ -73,6 +109,18 @@ PageBase { onToggled: Config.background.enabled = checked } + SelectRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + active: root.wallTransitions[Config.background.wallTransition] + menuItems: root.wallTransitions + menuOnTop: true + settingAnchor: "style-wallpaper-transition" + subtext: qsTr("Currently selected: %1").arg(active.text) + text: qsTr("Wallpaper transition") + + onSelected: item => Config.background.wallTransition = root.wallTransitions.indexOf(item) + } + ToggleRow { Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing checked: Colors.transparency.enabled diff --git a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml index ae9549b..13ecf6c 100644 --- a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml +++ b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -337,7 +337,7 @@ Item { } } - ClipRect { + Item { id: expandedTool anchors.top: collapsedRow.bottom @@ -348,8 +348,16 @@ Item { anchors.topMargin: Tokens.spacing.medium implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 - radius: Tokens.rounding.medium - color: Colors.palette.m3surfaceContainerLow + clip: true + + CustomRect { + id: bg + + anchors.fill: parent + + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + } CustomText { id: fullToolCall diff --git a/Modules/Sidebar/Chat/Content/ChatHost.qml b/Modules/Sidebar/Chat/Content/ChatHost.qml index d97d7d4..8e3eb3f 100644 --- a/Modules/Sidebar/Chat/Content/ChatHost.qml +++ b/Modules/Sidebar/Chat/Content/ChatHost.qml @@ -59,7 +59,6 @@ Item { id: container anchors.fill: parent - layer.enabled: opacity < 1 objectName: "ConversationContainer" Component.onCompleted: { diff --git a/Modules/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Sidebar/Chat/Content/ProcessBlock.qml index 6458e0c..e349081 100644 --- a/Modules/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Sidebar/Chat/Content/ProcessBlock.qml @@ -125,7 +125,7 @@ Item { current: root.current } - ClipRect { + Item { id: expandedRect anchors.left: parent.left @@ -133,11 +133,19 @@ Item { anchors.top: layout.bottom anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small - color: Colors.palette.m3surfaceContainerLow implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 - radius: Tokens.rounding.medium visible: opacity > 0 + clip: true + + CustomRect { + id: bg + + anchors.fill: parent + + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + } Behavior on opacity { Anim {} diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 43f93e5..8cc48df 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -20,6 +20,14 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) +pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) + +pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) +pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) +pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) +pkg_check_modules(LIBVA QUIET IMPORTED_TARGET libva) + +find_package(CUDAToolkit QUIET) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 7d1bbaf..7d69f21 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -37,6 +37,7 @@ list(APPEND CMAKE_AUTOMOC_MACRO_NAMES add_subdirectory(Settings) add_subdirectory(Models) add_subdirectory(Internal) +add_subdirectory(Wallpaper) add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) diff --git a/Plugins/ZShell/Config/background.hpp b/Plugins/ZShell/Config/background.hpp index d079a2a..1ef73f1 100644 --- a/Plugins/ZShell/Config/background.hpp +++ b/Plugins/ZShell/Config/background.hpp @@ -1,7 +1,10 @@ #pragma once -#include "common.hpp" + #include +#include "common.hpp" +#include "enums.hpp" + namespace ZShell::config { class Background : public settings::ObjectNode { @@ -9,6 +12,8 @@ class Background : public settings::ObjectNode { CONFIG_PROPERTY(bool, enabled, true) CONFIG_PROPERTY(int, wallFadeDuration, 300) + CONFIG_ENUM_PROPERTY( + WallpaperTransition, wallTransition, WallpaperTransition::Fade) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp index 9ba61a0..f2bb9b5 100644 --- a/Plugins/ZShell/Config/enums.hpp +++ b/Plugins/ZShell/Config/enums.hpp @@ -23,6 +23,19 @@ ENUM(NotifsFullscreen, On, Off) ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) ENUM(DataUnit, Binary, Decimal) ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) +ENUM( + WallpaperTransition, + Bowtie, + Fade, + LinearBlur, + WindowSlices, + WipeRight, + WipeDown, + TilesWave, + SplitSlideInHorizontal, + Hexagonalize, + DoomMelt, + Random) #undef ENUM diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index b2c039a..ff6b832 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,8 +1,3 @@ -wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") -wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") -wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") -wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") - qml_module(ZShell-internal URI ZShell.Internal INCLUDE_PREFIX internal @@ -13,10 +8,6 @@ qml_module(ZShell-internal circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp arcgauge.hpp arcgauge.cpp - wallpaperimage.hpp wallpaperimage.cpp - wallpaperservice.hpp wallpaperservice.cpp - wallpapersurface.hpp wallpapersurface.cpp - wallpaperobject.hpp wallpaperobject.cpp lidwatcher.hpp lidwatcher.cpp visualizerbars.hpp visualizerbars.cpp linearindicatormanager.hpp linearindicatormanager.cpp @@ -27,16 +18,14 @@ qml_module(ZShell-internal Qt::Gui Qt::Quick Qt::Concurrent + Qt::Sql Qt::Core Qt::Network Qt::DBus Qt::CanvasPainter Qt::GuiPrivate - wlp-layer-shell - wlp-xdg-shell - wlp-viewporter - wlp-fractional-scale PkgConfig::EGL PkgConfig::WAYLAND_EGL PkgConfig::GL + ZShell-config ) diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp deleted file mode 100644 index f8c9c10..0000000 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ /dev/null @@ -1,756 +0,0 @@ -#define EGL_EGLEXT_PROTOTYPES -#define GL_GLEXT_PROTOTYPES - -#include "wallpaperservice.hpp" - -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include - -#include - -namespace ZShell::internal { - -namespace { - -const char* kVertexShader = R"( -#version 300 es -void main() { - // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. - vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); -} -)"; - -const char* kFragmentShader = R"( -#version 300 es -precision highp float; -uniform sampler2D source; -uniform vec2 size; -uniform vec4 crop; -out vec4 outColor; -void main() { - // The pixels are stored top-down, so the fragment coordinate is flipped. - vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; - outColor = texture(source, crop.xy + p * crop.zw); -} -)"; - -constexpr int kWaitTimeoutMs = 20; - -void logShader(uint32_t object, bool program) { - int ok = 0; - if (program) - glGetProgramiv(object, GL_LINK_STATUS, &ok); - else - glGetShaderiv(object, GL_COMPILE_STATUS, &ok); - - if (ok) return; - - char info[512] = {}; - if (program) - glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info); - else - glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); - - qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") - << "failed:" << info; -} - -QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { - QVariantMap crop = crops.value(screen).toMap(); - - if (crop.isEmpty()) { - crop.insert("x", 0.0); - crop.insert("y", 0.0); - crop.insert("width", 1.0); - crop.insert("height", 1.0); - crop.insert("zoom", 1.0); - } - - return crop; -} - -} // namespace - -WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {} - -WallpaperService::~WallpaperService() { - m_stop = true; - wake(); - - if (m_thread.joinable()) m_thread.join(); - - for (auto* surface : m_surfaces) - delete surface; - m_surfaces.clear(); - - if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) { - if (eglMakeCurrent( - m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { - if (m_texture) glDeleteTextures(1, &m_texture); - if (m_program) glDeleteProgram(m_program); - } - - eglDestroyContext(m_eglDisplay, m_context); - } - - if (m_queue) wl_event_queue_destroy(m_queue); - - if (m_wake[0] >= 0) { - close(m_wake[0]); - close(m_wake[1]); - } - - if (m_layerShell) m_layerShell->destroy(); - if (m_viewporter) m_viewporter->destroy(); - if (m_scaleManager) m_scaleManager->destroy(); - if (m_registry) wl_registry_destroy(m_registry); -} - -QString WallpaperService::path() const { - std::lock_guard lock(m_mutex); - return m_path; -} - -void WallpaperService::setPath(const QString& path) { - if (m_path == path) return; - - { - std::lock_guard lock(m_mutex); - m_path = path; - - m_forceDecode = true; - m_imageChanged = true; - } - - emit pathChanged(); - - start(); - queueAll(); -} - -void WallpaperService::addScreen(const QString& screen) { - { - std::lock_guard lock(m_mutex); - - if (m_screens.contains(screen)) return; - - m_screens.append(screen); - } - - start(); - - queueAll(); -} - -void WallpaperService::removeScreen(const QString& screen) { - { - std::lock_guard lock(m_mutex); - - if (!m_screens.removeOne(screen)) return; - } - - queueAll(); -} - -const QVariantMap& WallpaperService::crops() const { - std::lock_guard lock(m_mutex); - return m_crops; -} - -void WallpaperService::setCrops(const QVariantMap& crops) { - { - std::lock_guard lock(m_mutex); - - if (m_crops == crops) return; - - m_crops = crops; - } - - emit cropsChanged(); - queueAll(); -} - -void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) { - WallpaperSurface* surface = nullptr; - - { - std::lock_guard lock(m_mutex); - - if (m_crops.value(screen) == crop) return; - - m_crops.insert(screen, crop); - - for (auto* candidate : m_surfaces) { - if (candidate->screen() == screen) { - surface = candidate; - break; - } - } - } - - emit cropsChanged(); - - if (surface) queue(surface); -} - -void WallpaperService::redraw() { - queueAll(); -} - -void WallpaperService::start() { - { - std::lock_guard lock(m_mutex); - - if (started || m_path.isEmpty() || m_screens.isEmpty()) return; - - started = true; - } - - auto* interface = - qApp->nativeInterface(); - if (!interface) { - qWarning() << "WallpaperService: this is not a Wayland session"; - return; - } - - m_display = interface->display(); - m_compositor = interface->compositor(); - if (!m_display || !m_compositor) return; - - m_queue = wl_display_create_queue(m_display); - if (!m_queue) return; - - if (pipe2(m_wake, O_CLOEXEC) < 0) { - qWarning() << "WallpaperService: could not make the pipe to wake on"; - return; - } - - bindGlobals(); - - if (!setupGl()) return; - - m_drawing = true; - m_thread = std::thread([this] { loop(); }); - - wake(); -} - -void WallpaperService::wake() { - if (m_wake[1] < 0) return; - - char byte = 0; - - if (::write(m_wake[1], &byte, 1) < 0) - qWarning() << "WallpaperService: could not wake the drawing thread"; -} - -void WallpaperService::queue(WallpaperSurface* surface) { - { - std::lock_guard lock(m_mutex); - m_pending.push_back(surface); - } - - wake(); -} - -void WallpaperService::queueAll() { - { - std::lock_guard lock(m_mutex); - - for (auto* surface : m_surfaces) - m_pending.push_back(surface); - } - - wake(); -} - -void WallpaperService::loop() { - while (!m_stop) { - present(); - - if (wl_display_prepare_read(m_display) == 0) { - wl_display_flush(m_display); - - struct pollfd fds[2] = { - {wl_display_get_fd(m_display), POLLIN, 0}, - {m_wake[0], POLLIN, 0}, - }; - - int ready = poll(fds, 2, kWaitTimeoutMs); - - if (ready > 0 && fds[0].revents) { - wl_display_read_events(m_display); - continue; - } - - if (ready > 0 && fds[1].revents) { - char byte = 0; - ::read(m_wake[0], &byte, 1); - } - - wl_display_cancel_read(m_display); - continue; - } - - struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}}; - - if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue; - - char byte = 0; - ::read(m_wake[0], &byte, 1); - } -} - -void WallpaperService::present() { - wl_display_dispatch_queue_pending(m_display, m_queue); - - addSurfaces(); - - ensureDecodeSize(); - - std::vector pending; - { - std::lock_guard lock(m_mutex); - pending.swap(m_pending); - } - - for (auto* surface : pending) - drawSurface(surface); -} - -void WallpaperService::addSurfaces() { - if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return; - - QList gone; - QList needed; - - { - std::lock_guard lock(m_mutex); - - QList kept; - for (auto* surface : m_surfaces) { - if (m_screens.contains(surface->screen())) - kept.append(surface); - else - gone.append(surface); - } - - m_surfaces = kept; - - for (const auto& screen : m_screens) { - bool exists = false; - for (auto* surface : m_surfaces) { - if (surface->screen() == screen) { - exists = true; - break; - } - } - - if (!exists) needed.append(screen); - } - } - - for (auto* surface : gone) - delete surface; - - for (const auto& screen : needed) - addSurface(screen); -} - -void WallpaperService::registryGlobal( - void* data, - struct wl_registry* registry, - uint32_t name, - const char* interface, - uint32_t version) { - auto* service = static_cast(data); - - void* proxy = nullptr; - - if (strcmp(interface, "zwlr_layer_shell_v1") == 0) { - proxy = new QtWayland::zwlr_layer_shell_v1( - registry, int(name), int(version)); - } else if (strcmp(interface, "wp_viewporter") == 0) { - proxy = new QtWayland::wp_viewporter(registry, int(name), int(version)); - } else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) { - proxy = new QtWayland::wp_fractional_scale_manager_v1( - registry, int(name), int(version)); - } else { - return; - } - - service->storeGlobal(interface, proxy); - - service->queueAll(); -} - -void WallpaperService::storeGlobal(const char* interface, void* proxy) { - std::lock_guard lock(m_mutex); - - if (strcmp(interface, "zwlr_layer_shell_v1") == 0) - m_layerShell = static_cast(proxy); - else if (strcmp(interface, "wp_viewporter") == 0) - m_viewporter = static_cast(proxy); - else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) - m_scaleManager = - static_cast(proxy); -} - -void WallpaperService::bindGlobals() { - m_registry = wl_display_get_registry(m_display); - - static const struct wl_registry_listener listener = { - registryGlobal, - nullptr, - }; - - wl_registry_add_listener(m_registry, &listener, this); -} - -bool WallpaperService::setupGl() { - const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); - if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) { - m_eglDisplay = - eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr); - } else { - m_eglDisplay = - eglGetDisplay(reinterpret_cast(m_display)); - } - - if (m_eglDisplay == EGL_NO_DISPLAY) { - qWarning() << "WallpaperService: no EGL display"; - return false; - } - - EGLint major = 0, minor = 0; - if (!eglInitialize(m_eglDisplay, &major, &minor)) { - qWarning() << "WallpaperService: eglInitialize failed"; - return false; - } - - const EGLint configAttributes[] = { - EGL_SURFACE_TYPE, - EGL_WINDOW_BIT, - EGL_RENDERABLE_TYPE, - EGL_OPENGL_ES2_BIT, - EGL_RED_SIZE, - 8, - EGL_GREEN_SIZE, - 8, - EGL_BLUE_SIZE, - 8, - EGL_ALPHA_SIZE, - 8, - EGL_NONE}; - - EGLint count = 0; - if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || - count == 0) { - qWarning() << "WallpaperService: no EGL config for window surfaces"; - return false; - } - - const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; - m_context = eglCreateContext( - m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); - if (m_context == EGL_NO_CONTEXT) { - qWarning() << "WallpaperService: eglCreateContext failed"; - return false; - } - - return true; -} - -uint32_t WallpaperService::createProgram() { - uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertex, 1, &kVertexShader, nullptr); - glCompileShader(vertex); - logShader(vertex, false); - - uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragment, 1, &kFragmentShader, nullptr); - glCompileShader(fragment); - logShader(fragment, false); - - uint32_t program = glCreateProgram(); - glAttachShader(program, vertex); - glAttachShader(program, fragment); - glLinkProgram(program); - logShader(program, true); - - if (glGetUniformLocation(program, "size") < 0 || - glGetUniformLocation(program, "crop") < 0) { - qWarning() << "WallpaperService: the program does not have the " - "uniforms it needs"; - glDeleteProgram(program); - return 0; - } - - m_sizeUniform = glGetUniformLocation(program, "size"); - m_cropUniform = glGetUniformLocation(program, "crop"); - - glDeleteShader(vertex); - glDeleteShader(fragment); - return program; -} - -void WallpaperService::uploadImage() { - if (m_image.isNull()) return; - - if (m_texture == 0) { - glGenTextures(1, &m_texture); - glBindTexture(GL_TEXTURE_2D, m_texture); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - } else { - glBindTexture(GL_TEXTURE_2D, m_texture); - } - - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - glTexImage2D( - GL_TEXTURE_2D, - 0, - GL_RGBA8, - m_image.width(), - m_image.height(), - 0, - GL_RGBA, - GL_UNSIGNED_BYTE, - m_image.constBits()); -} - -bool WallpaperService::decode(const QString& path) { - if (path.isEmpty()) return false; - - QImageReader reader(path); - reader.setAutoTransform(true); - - QSize size = reader.size(); - if (size.isValid()) m_sourceSize = size; - - if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) - reader.setScaledSize( - m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); - - QImage image = reader.read(); - if (image.isNull()) { - qWarning() << "WallpaperService: could not read the image" << path; - return false; - } - - m_image = image.convertToFormat(QImage::Format_RGBA8888); - return !m_image.isNull(); -} - -bool WallpaperService::ensureDecodeSize() { - QSize largest; - QString path; - - { - std::lock_guard lock(m_mutex); - - largest = largestBufferSize(); - if (largest.isEmpty()) return false; - - if (!m_forceDecode && !m_decodeSize.isEmpty() && - qint64(largest.width()) * largest.height() <= - qint64(m_decodeSize.width()) * m_decodeSize.height()) - return false; - - QSize target = - m_sourceSize.isEmpty() - ? largest - : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); - - if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) - return false; - - m_forceDecode = false; - m_decodeSize = largest; - path = m_path; - } - - if (!decode(path)) return false; - - { - std::lock_guard lock(m_mutex); - m_imageChanged = true; - } - - return true; -} - -QSize WallpaperService::largestBufferSize() { - QSize largest; - - for (auto* surface : m_surfaces) { - QSize buffer = surface->bufferSize(); - if (buffer.isEmpty()) continue; - - if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() > - qint64(largest.width()) * largest.height()) - largest = buffer; - } - - return largest; -} - -QRectF WallpaperService::sourceRect( - const QVariantMap& crops, const QString& screen, const QSize& size) const { - QVariantMap crop = cropFor(crops, screen); - - QRectF rect( - crop.value("x", 0.0).toDouble(), - crop.value("y", 0.0).toDouble(), - crop.value("width", 1.0).toDouble(), - crop.value("height", 1.0).toDouble()); - - if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; - - qreal imageWidth = qreal(m_image.width()); - qreal imageHeight = qreal(m_image.height()); - - QRectF pixels( - rect.x() * imageWidth, - rect.y() * imageHeight, - rect.width() * imageWidth, - rect.height() * imageHeight); - - qreal targetRatio = qreal(size.width()) / qreal(size.height()); - qreal rectRatio = pixels.width() / pixels.height(); - - if (rectRatio > targetRatio) { - qreal newWidth = pixels.height() * targetRatio; - pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0); - pixels.setWidth(newWidth); - } else if (rectRatio < targetRatio) { - qreal newHeight = pixels.width() / targetRatio; - pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0); - pixels.setHeight(newHeight); - } - - return QRectF( - pixels.x() / imageWidth, - pixels.y() / imageHeight, - pixels.width() / imageWidth, - pixels.height() / imageHeight); -} - -void WallpaperService::drawSurface(WallpaperSurface* surface) { - EGLSurface target = surface->eglSurface(); - if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || - surface->bufferSize().isEmpty()) - return; - - if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { - qWarning() << "WallpaperService: the context cannot be made current"; - return; - } - - // Release the context when this scope ends (see FrameService::drawSurface). - // Holding it across the draw loop's sleep serializes the two raw-EGL clients on - // NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts. - struct ContextRelease { - EGLDisplay display; - ~ContextRelease() { - eglMakeCurrent( - display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - } - } contextRelease{m_eglDisplay}; - - QVariantMap crops; - QSize size; - bool upload = false; - - { - std::lock_guard lock(m_mutex); - - crops = m_crops; - size = surface->bufferSize(); - - upload = m_imageChanged; - m_imageChanged = false; - } - - if (size.isEmpty()) return; - - if (m_program == 0) m_program = createProgram(); - if (m_texture == 0 || upload) uploadImage(); - - if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; - - glUseProgram(m_program); - glBindTexture(GL_TEXTURE_2D, m_texture); - - glUniform2f( - GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); - - QRectF rect = sourceRect(crops, surface->screen(), size); - glUniform4f( - GLint(m_cropUniform), - GLfloat(rect.x()), - GLfloat(rect.y()), - GLfloat(rect.width()), - GLfloat(rect.height())); - - glViewport(0, 0, size.width(), size.height()); - glDrawArrays(GL_TRIANGLES, 0, 3); - - if (!eglSwapBuffers(m_eglDisplay, target)) - qWarning() - << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" - << QString::number(unsigned(eglGetError()), 16); -} - -void WallpaperService::addSurface(const QString& screen) { - if (!m_layerShell || !m_viewporter || !m_scaleManager) return; - - QScreen* qscreen = nullptr; - for (auto* candidate : QGuiApplication::screens()) { - if (candidate->name() == screen) { - qscreen = candidate; - break; - } - } - - if (!qscreen) { - qWarning() << "WallpaperService:" << screen << "is not a screen"; - return; - } - - auto* waylandScreen = - dynamic_cast(qscreen->handle()); - if (!waylandScreen || !waylandScreen->output()) { - qWarning() << "WallpaperService:" << screen - << "is not a Wayland screen"; - return; - } - - wl_surface* wlSurface = wl_compositor_create_surface(m_compositor); - if (!wlSurface) return; - - auto* surface = - new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen); - - { - std::lock_guard lock(m_mutex); - m_surfaces.append(surface); - } -} - -} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp deleted file mode 100644 index a68d84d..0000000 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ /dev/null @@ -1,147 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "wallpapersurface.hpp" - -namespace ZShell::internal { - -class WallpaperSurface; - -class WallpaperService : public QObject { - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) - Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) - - public: - explicit WallpaperService(QObject* parent = nullptr); - ~WallpaperService() override; - - [[nodiscard]] QString path() const; - void setPath(const QString& path); - - void addScreen(const QString& screen); - void removeScreen(const QString& screen); - - [[nodiscard]] const QVariantMap& crops() const; - void setCrops(const QVariantMap& crops); - - Q_INVOKABLE void redraw(); - - Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); - - signals: - void pathChanged(); - void cropsChanged(); - - private: - friend class WallpaperSurface; - - void bindGlobals(); - static void registryGlobal( - void* data, - struct wl_registry* registry, - uint32_t name, - const char* interface, - uint32_t version); - - void storeGlobal(const char* interface, void* proxy); - - [[nodiscard]] bool setupGl(); - [[nodiscard]] uint32_t createProgram(); - - void uploadImage(); - - bool decode(const QString& path); - - bool ensureDecodeSize(); - - [[nodiscard]] QSize largestBufferSize(); - - void queue(WallpaperSurface* surface); - void queueAll(); - - void startDrawing(); - void loop(); - - void present(); - - void drawSurface(WallpaperSurface* surface); - - void wake(); - - [[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; } - - [[nodiscard]] QRectF sourceRect( - const QVariantMap& crops, - const QString& screen, - const QSize& size) const; - - void start(); - - void addSurfaces(); - void addSurface(const QString& screen); - - QString m_path; - QStringList m_screens; - QVariantMap m_crops; - - QImage m_image; - - QSize m_sourceSize; - QSize m_decodeSize; - - wl_display* m_display = nullptr; - wl_registry* m_registry = nullptr; - wl_compositor* m_compositor = nullptr; - QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr; - QtWayland::wp_viewporter* m_viewporter = nullptr; - QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr; - - EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; - EGLConfig m_config = nullptr; - EGLContext m_context = EGL_NO_CONTEXT; - uint32_t m_texture = 0; - uint32_t m_program = 0; - int m_sizeUniform = -1; - int m_cropUniform = -1; - - QList m_surfaces; - - std::thread m_thread; - mutable std::recursive_mutex m_mutex; - std::vector m_pending; - wl_event_queue* m_queue = nullptr; - - int m_wake[2] = {-1, -1}; - - bool m_stop = false; - bool m_drawing = false; - - bool started = false; - - bool m_forceDecode = false; - bool m_imageChanged = false; -}; - -} // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt index 9bac1a8..6d677a0 100644 --- a/Plugins/ZShell/Models/CMakeLists.txt +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -2,7 +2,12 @@ qml_module(ZShell-models URI ZShell.Models SOURCES filesystemmodel.hpp filesystemmodel.cpp + wallpaperdb.hpp wallpaperdb.cpp + wallpaperutil.hpp LIBRARIES Qt::Gui + Qt::Quick + Qt::Qml Qt::Concurrent + Qt::Sql ) diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp index 02f18f0..72f423c 100644 --- a/Plugins/ZShell/Models/filesystemmodel.cpp +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -1,11 +1,84 @@ #include "filesystemmodel.hpp" +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include +#include +#include + namespace ZShell::models { +const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +bool hasVideoExtension(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; + +bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + process.waitForFinished(5000); + + if (process.exitCode() != 0) { + return false; + } + + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); +} + FileSystemEntry::FileSystemEntry( const QString& path, const QString& relativePath, QObject* parent) : QObject(parent) @@ -56,6 +129,14 @@ bool FileSystemEntry::isImage() const { return m_isImage; } +bool FileSystemEntry::isVideo() const { + return hasVideoExtension(m_path); +} + +QString FileSystemEntry::thumbnailPath() const { + return m_path; +} + QString FileSystemEntry::mimeType() const { if (!m_mimeTypeInitialised) { const QMimeDatabase db; @@ -221,6 +302,46 @@ void FileSystemModel::setNameFilters(const QStringList& nameFilters) { update(); } +bool FileSystemModel::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void FileSystemModel::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + update(); +} + +bool FileSystemModel::isHardwareVideo(const QString& path) { + const QFileInfo info(path); + const QString key = + path + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch() / 1000); + + { + std::lock_guard lock(m_codecMutex); + + const auto it = m_codecCache.constFind(key); + if (it != m_codecCache.constEnd()) { + return it.value(); + } + } + + const bool hardware = probeHardwareCodec(path); + + { + std::lock_guard lock(m_codecMutex); + m_codecCache.insert(key, hardware); + } + + return hardware; +} + QQmlListProperty FileSystemModel::entries() { return QQmlListProperty(this, &m_entries); } @@ -251,14 +372,12 @@ void FileSystemModel::watchDirIfRecursive(const QString& path) { const auto paths = watcher->result(); if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) { - // Ignore if dir or showHidden has changed m_watcher.addPaths(paths); } watcher->deleteLater(); }); watcher->setFuture(future); } - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } void FileSystemModel::update() { @@ -305,6 +424,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { const auto showHidden = m_showHidden; const auto filter = m_filter; const auto nameFilters = m_nameFilters; + const auto requireHardware = m_requireHardwareCodec; QSet oldPaths; for (const auto& entry : std::as_const(m_entries)) { @@ -312,17 +432,26 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { } const auto future = QtConcurrent::run( - [=](QPromise, QSet>>& promise) { + [=, this](QPromise, QSet>>& promise) { const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; std::optional iter; - if (filter == Images) { + if (filter == Images || filter == Videos || filter == Media) { QStringList extraNameFilters = nameFilters; - const auto formats = QImageReader::supportedImageFormats(); - for (const auto& format : formats) { - extraNameFilters << "*." + format; + + if (filter == Images || filter == Media) { + const auto formats = QImageReader::supportedImageFormats(); + for (const auto& format : formats) { + extraNameFilters << "*." + format; + } + } + + if (filter == Videos || filter == Media) { + for (const auto& ext : kVideoExtensions) { + extraNameFilters << "*." + ext; + } } QDir::Filters filters = QDir::Files; @@ -361,7 +490,11 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { QString path = iter->next(); - if (filter == Images) { + if (hasVideoExtension(path)) { + if (requireHardware && !isHardwareVideo(path)) { + continue; + } + } else if (filter == Images || filter == Media) { QImageReader reader(path); if (!reader.canRead()) { continue; @@ -406,7 +539,6 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { }); watcher->setFuture(future); - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } void FileSystemModel::applyChanges( @@ -419,7 +551,6 @@ void FileSystemModel::applyChanges( } std::sort(removedIndices.begin(), removedIndices.end(), std::greater()); - // Batch remove old entries int start = -1; int end = -1; for (int idx : std::as_const(removedIndices)) { @@ -447,11 +578,11 @@ void FileSystemModel::applyChanges( endRemoveRows(); } - // Create new entries QList newEntries; for (const auto& path : addedPaths) { - newEntries << new FileSystemEntry( - path, m_dir.relativeFilePath(path), this); + auto* entry = + new FileSystemEntry(path, m_dir.relativeFilePath(path), this); + newEntries << entry; } std::sort( newEntries.begin(), @@ -460,7 +591,6 @@ void FileSystemModel::applyChanges( return compareEntries(a, b); }); - // Batch insert new entries int insertStart = -1; QList batchItems; for (const auto& entry : std::as_const(newEntries)) { diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp index 94bb29a..2cabeb9 100644 --- a/Plugins/ZShell/Models/filesystemmodel.hpp +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -28,6 +30,8 @@ class FileSystemEntry : public QObject { Q_PROPERTY(qint64 size READ size CONSTANT) Q_PROPERTY(bool isDir READ isDir CONSTANT) Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(bool isVideo READ isVideo CONSTANT) + Q_PROPERTY(QString thumbnailPath READ thumbnailPath CONSTANT) Q_PROPERTY(QString mimeType READ mimeType CONSTANT) public: @@ -45,6 +49,8 @@ class FileSystemEntry : public QObject { [[nodiscard]] qint64 size() const; [[nodiscard]] bool isDir() const; [[nodiscard]] bool isImage() const; + [[nodiscard]] bool isVideo() const; + [[nodiscard]] QString thumbnailPath() const; [[nodiscard]] QString mimeType() const; void updateRelativePath(const QDir& dir); @@ -85,13 +91,16 @@ class FileSystemModel : public QAbstractListModel { Q_PROPERTY( QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) Q_PROPERTY( QQmlListProperty entries READ entries NOTIFY entriesChanged) public: - enum Filter { NoFilter, Images, Files, Dirs }; + enum Filter { NoFilter, Images, Videos, Media, Files, Dirs }; Q_ENUM(Filter) explicit FileSystemModel(QObject* parent = nullptr); @@ -123,6 +132,9 @@ class FileSystemModel : public QAbstractListModel { [[nodiscard]] QStringList nameFilters() const; void setNameFilters(const QStringList& nameFilters); + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + [[nodiscard]] QQmlListProperty entries(); signals: @@ -133,6 +145,7 @@ class FileSystemModel : public QAbstractListModel { void sortReverseChanged(); void filterChanged(); void nameFiltersChanged(); + void requireHardwareCodecChanged(); void entriesChanged(); private: @@ -148,6 +161,10 @@ class FileSystemModel : public QAbstractListModel { bool m_sortReverse; Filter m_filter; QStringList m_nameFilters; + bool m_requireHardwareCodec = false; + + QHash m_codecCache; + QMutex m_codecMutex; void watchDirIfRecursive(const QString& path); void update(); @@ -156,6 +173,7 @@ class FileSystemModel : public QAbstractListModel { void updateEntriesForDir(const QString& dir); void applyChanges( const QSet& removedPaths, const QSet& addedPaths); + [[nodiscard]] bool isHardwareVideo(const QString& path); [[nodiscard]] bool compareEntries( const FileSystemEntry* a, const FileSystemEntry* b) const; }; diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp new file mode 100644 index 0000000..ace438d --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -0,0 +1,876 @@ +#include "wallpaperdb.hpp" +#include "wallpaperutil.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +Q_LOGGING_CATEGORY(dbLog, "ZShell.Internal.wallpaper.db", QtWarningMsg) + +namespace ZShell::models { + +class WallpaperImageProvider : public QQuickImageProvider { + public: + explicit WallpaperImageProvider(const QString& dbPath) + : QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {} + + void setDbPath(const QString& dbPath) { + std::lock_guard lock(m_mutex); + if (m_dbPath == dbPath) { + return; + } + + m_dbPath = dbPath; + m_generation.fetch_add(1, std::memory_order_relaxed); + } + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + const QString path = + QString::fromUtf8(QByteArray::fromHex(id.toUtf8())); + if (path.isEmpty() || !openThreadDb(currentDbPath())) { + return {}; + } + + QSqlQuery query(QSqlDatabase::database(m_threadUuid)); + query.prepare("SELECT cache FROM wallpapers WHERE path = ?"); + query.addBindValue(path); + + if (!query.exec() || !query.next()) { + return {}; + } + + const QByteArray bytes = query.value(0).toByteArray(); + if (bytes.isEmpty()) { + return {}; + } + + QImage image = QImage::fromData(bytes, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (size) { + *size = image.size(); + } + + if (requestedSize.isValid() && !requestedSize.isEmpty() && + (image.width() > requestedSize.width() || + image.height() > requestedSize.height())) { + image = image.scaled( + requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + return image; + } + + private: + QString currentDbPath() const { + std::lock_guard lock(m_mutex); + return m_dbPath; + } + + bool openThreadDb(const QString& dbPath) { + const quint64 generation = m_generation.load(std::memory_order_relaxed); + + if (!m_threadUuid.isEmpty() && m_threadGeneration == generation) { + return true; + } + + if (!m_threadUuid.isEmpty()) { + { + auto db = QSqlDatabase::database(m_threadUuid, false); + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_threadUuid); + m_threadUuid.clear(); + } + + if (dbPath.isEmpty()) { + return false; + } + + const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + + auto db = QSqlDatabase::addDatabase("QSQLITE", uuid); + db.setConnectOptions("QSQLITE_OPEN_READONLY"); + db.setDatabaseName(dbPath); + + if (!db.open()) { + return false; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + + m_threadUuid = uuid; + m_threadGeneration = generation; + return true; + } + + mutable std::mutex m_mutex; + QString m_dbPath; + std::atomic m_generation = 0; + + thread_local static QString m_threadUuid; + thread_local static quint64 m_threadGeneration; +}; + +thread_local QString WallpaperImageProvider::m_threadUuid; +thread_local quint64 WallpaperImageProvider::m_threadGeneration; + +WallpaperEntry::WallpaperEntry( + const QString& path, const QString& relativePath, QObject* parent) + : QObject(parent) + , m_fileInfo(path) + , m_path(path) + , m_relativePath(relativePath) {} + +QString WallpaperEntry::path() const { + return m_path; +} + +QString WallpaperEntry::relativePath() const { + return m_relativePath; +} + +QString WallpaperEntry::name() const { + return m_fileInfo.fileName(); +} + +QString WallpaperEntry::baseName() const { + return m_fileInfo.baseName(); +} + +QString WallpaperEntry::parentDir() const { + return m_fileInfo.absolutePath(); +} + +QString WallpaperEntry::suffix() const { + return m_fileInfo.completeSuffix(); +} + +qint64 WallpaperEntry::size() const { + return m_fileInfo.size(); +} + +bool WallpaperEntry::isImage() const { + return !hasVideoExtension(m_path); +} + +bool WallpaperEntry::isVideo() const { + return hasVideoExtension(m_path); +} + +bool WallpaperEntry::hardware() const { + return m_hardware; +} + +bool WallpaperEntry::cached() const { + return !m_thumbnailPath.isEmpty(); +} + +QString WallpaperEntry::thumbnailPath() const { + if (!m_thumbnailPath.isEmpty()) { + return m_thumbnailPath; + } + + return isImage() ? m_path : QString(); +} + +void WallpaperEntry::setRelativePath(const QString& path) { + if (m_relativePath == path) { + return; + } + + m_relativePath = path; + emit relativePathChanged(); +} + +void WallpaperEntry::setHardware(bool hardware) { + if (m_hardware == hardware) { + return; + } + + m_hardware = hardware; + emit hardwareChanged(); +} + +void WallpaperEntry::setThumbnailPath(const QString& path) { + if (m_thumbnailPath == path) { + return; + } + + m_thumbnailPath = path; + emit thumbnailPathChanged(); +} + +WallpaperDb::WallpaperDb(QObject* parent) : QObject(parent) { + m_uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_dbPath = QDir::homePath() + "/.local/state/zshell/wallpapers.sqlite"; + + m_queueTimer.setInterval(300); + m_queueTimer.setTimerType(Qt::CoarseTimer); + QObject::connect( + &m_queueTimer, &QTimer::timeout, this, &WallpaperDb::processQueue); + + m_watchTimer.setInterval(400); + m_watchTimer.setSingleShot(true); + QObject::connect( + &m_watcher, &QFileSystemWatcher::directoryChanged, this, [this]() { + m_watchTimer.start(); + }); + QObject::connect( + &m_watchTimer, &QTimer::timeout, this, &WallpaperDb::scanDir); +} + +WallpaperDb::~WallpaperDb() { + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } +} + +QString WallpaperDb::path() const { + return m_path; +} + +void WallpaperDb::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + m_dir = QDir(path); + emit pathChanged(); + + for (const auto& dir : m_watcher.directories()) { + m_watcher.removePath(dir); + } + + scanDir(); +} + +QString WallpaperDb::dbPath() const { + return m_dbPath; +} + +void WallpaperDb::setDbPath(const QString& path) { + if (m_dbPath == path) { + return; + } + + m_dbPath = path; + emit dbPathChanged(); + + if (!m_path.isEmpty()) { + scanDir(); + } +} + +bool WallpaperDb::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void WallpaperDb::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + rebuildEntries(); +} + +QQmlListProperty WallpaperDb::entries() { + return QQmlListProperty(this, &m_entries); +} + +QString WallpaperDb::thumbnailUrl(const QString& path) { + return QStringLiteral("image://wallpapers/") + + QString::fromUtf8(path.toUtf8().toHex()); +} + +QSize WallpaperDb::cacheSize() const { + return m_cacheSize; +} + +void WallpaperDb::setCacheSize(const QSize& cacheSize) { + if (m_cacheSize == cacheSize) { + return; + } + + const QSize prev = m_cacheSize; + const bool hadPrevious = !prev.isEmpty(); + + m_cacheSize = cacheSize; + emit cacheSizeChanged(); + + if (hadPrevious && qint64(cacheSize.width()) * cacheSize.height() > + qint64(prev.width()) * prev.height()) { + const auto db = QSqlDatabase::database(m_uuid, false); + + for (auto* entry : std::as_const(m_byPath)) { + if (!entry->cached()) { + continue; + } + + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = NULL, scheme_thumb = NULL," + " poster = NULL WHERE path = ?"); + query.addBindValue(entry->path()); + query.exec(); + } + + entry->setThumbnailPath(QString()); + queueMetadata(entry, entry->isVideo()); + } + + rebuildEntries(); + } + + if (!m_queue.isEmpty() && !m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::registerProvider() { + if (m_provider != nullptr) { + m_provider->setDbPath(m_dbPath); + return; + } + + auto* engine = qmlEngine(this); + if (engine == nullptr) { + return; + } + + m_provider = new WallpaperImageProvider(m_dbPath); + engine->addImageProvider(QStringLiteral("wallpapers"), m_provider); +} + +void WallpaperDb::openDb() { + if (m_dbPath.isEmpty()) { + return; + } + + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen() && db.databaseName() == m_dbPath) { + registerProvider(); + return; + } + + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } + + QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); + + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(m_dbPath); + + if (!db.open()) { + qWarning(dbLog) << "WallpaperDb: could not open database" << m_dbPath + << db.lastError(); + return; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + query.exec( + "CREATE TABLE IF NOT EXISTS wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB," + " poster BLOB, crop TEXT)"); + + query.exec("SELECT cache FROM wallpapers LIMIT 1"); + if (query.lastError().isValid()) { + query.exec("DROP TABLE IF EXISTS wallpapers"); + query.exec( + "CREATE TABLE wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB," + " poster BLOB, crop TEXT)"); + } else { + query.exec("ALTER TABLE wallpapers ADD COLUMN scheme_thumb BLOB"); + query.exec("ALTER TABLE wallpapers ADD COLUMN poster BLOB"); + query.exec("ALTER TABLE wallpapers ADD COLUMN crop TEXT"); + } + + registerProvider(); +} + +void WallpaperDb::scanDir() { + openDb(); + + watchDir(m_dir.absolutePath()); + + if (!m_dir.exists()) { + if (!m_entries.isEmpty()) { + m_entries.clear(); + emit entriesChanged(); + } + + return; + } + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, &QFutureWatcher::finished, this, [this, watcher]() { + applyScan(watcher->result()); + watcher->deleteLater(); + }); + + const QDir dir = m_dir; + + QHash> knownCodecs; + for (const auto* entry : std::as_const(m_byPath)) { + if (entry->isVideo()) { + knownCodecs.insert( + entry->path(), qMakePair(entry->size(), entry->hardware())); + } + } + + watcher->setFuture(QtConcurrent::run([dir, knownCodecs]() { + ScanResult result; + result.dir = dir; + + QDirIterator it(dir.absolutePath(), QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + const QFileInfo info(path); + + if (info.isDir()) { + continue; + } + + if (hasVideoExtension(path)) { + FileEntry file; + file.info = info; + + const auto known = knownCodecs.constFind(path); + if (known != knownCodecs.constEnd() && + known.value().first == info.size()) { + file.hardware = known.value().second; + } else { + file.hardware = probeHardwareCodec(path); + } + + result.files.append(file); + } else { + QImageReader reader(path); + if (reader.canRead()) { + FileEntry file; + file.info = info; + result.files.append(file); + } + } + } + + return result; + })); +} + +void WallpaperDb::watchDir(const QString& dir) { + if (dir.isEmpty() || m_watcher.directories().contains(dir)) { + return; + } + + if (!QDir(dir).exists()) { + return; + } + + m_watcher.addPath(dir); +} + +void WallpaperDb::applyScan(const ScanResult& result) { + qCInfo( + dbLog, + "WallpaperDb: scan of %s found %d files (cache target %dx%d)", + m_dir.absolutePath().toUtf8().constData(), + int(result.files.size()), + m_cacheSize.width(), + m_cacheSize.height()); + + const auto db = QSqlDatabase::database(m_uuid, false); + + QSet seen; + for (const auto& file : result.files) { + seen.insert(file.info.absoluteFilePath()); + } + + for (auto it = m_byPath.begin(); it != m_byPath.end();) { + if (!seen.contains(it.key())) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare("DELETE FROM wallpapers WHERE path = ?"); + query.addBindValue(it.key()); + query.exec(); + } + + delete it.value(); + m_crops.remove(it.key()); + it = m_byPath.erase(it); + } else { + ++it; + } + } + + for (const auto& file : result.files) { + const QFileInfo& info = file.info; + const QString path = info.absoluteFilePath(); + + const qint64 size = info.size(); + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + const bool isVideo = hasVideoExtension(path); + + watchDir(info.absolutePath()); + + const auto existing = m_byPath.constFind(path); + if (existing != m_byPath.constEnd()) { + if (existing.value()->size() == size) { + existing.value()->setHardware(file.hardware); + continue; + } + } + + WallpaperEntry* entry = m_byPath.value(path); + if (!entry) { + entry = + new WallpaperEntry(path, m_dir.relativeFilePath(path), this); + m_byPath.insert(path, entry); + } + + entry->setHardware(file.hardware); + + bool reused = false; + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "SELECT cache, scheme_thumb, poster, size, mtime, crop" + " FROM wallpapers WHERE path = ?"); + query.addBindValue(path); + query.exec(); + + if (query.next()) { + const auto cache = query.value(0).toByteArray(); + const auto scheme = query.value(1).toByteArray(); + const auto poster = query.value(2).toByteArray(); + const auto storedSize = query.value(3).toLongLong(); + const auto storedMtime = query.value(4).toLongLong(); + loadCrop(path, query.value(5).toString()); + + const bool posterOk = !isVideo || !poster.isEmpty(); + + if (storedSize == size && storedMtime == mtime && + !cache.isEmpty()) { + reused = true; + entry->setThumbnailPath(thumbnailUrl(path)); + + if (scheme.isEmpty() || !posterOk) { + queueMetadata(entry, isVideo, cache, poster); + } + } + } + } + + if (!reused) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "INSERT INTO wallpapers" + " (path, kind, hardware, size, mtime, cache)" + " VALUES (?, ?, ?, ?, ?, NULL)" + " ON CONFLICT(path) DO UPDATE SET kind = excluded.kind," + " hardware = excluded.hardware, size = excluded.size," + " mtime = excluded.mtime, cache = NULL," + " scheme_thumb = NULL, poster = NULL"); + query.addBindValue(path); + query.addBindValue(isVideo ? "video" : "image"); + query.addBindValue( + isVideo ? (file.hardware ? 1 : 0) : QVariant()); + query.addBindValue(size); + query.addBindValue(mtime); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the row for" << path + << query.lastError(); + } + } + + queueMetadata(entry, isVideo); + } + } + + rebuildEntries(); +} + +void WallpaperDb::queueMetadata( + WallpaperEntry* entry, + bool isVideo, + const QByteArray& existingCache, + const QByteArray& existingPoster) { + qCInfo( + dbLog, + "WallpaperDb: queued cache job for %s (%s%s)", + entry->path().toUtf8().constData(), + isVideo ? "video" : "image", + existingCache.isEmpty() ? "" : ", existing blob"); + + m_queue.append( + MetadataJob{entry, entry->path(), isVideo, existingCache, existingPoster}); + + if (!m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::processQueue() { + if (m_jobRunning) { + return; + } + + if (m_queue.isEmpty()) { + m_queueTimer.stop(); + return; + } + + if (m_cacheSize.isEmpty()) { + return; + } + + const MetadataJob job = m_queue.takeFirst(); + m_jobRunning = true; + + qCInfo( + dbLog, + "WallpaperDb: caching %s (%s, %d jobs left in queue)", + job.path.toUtf8().constData(), + job.isVideo ? "video" : "image", + int(m_queue.size())); + + runMetadataJob(job); +} + +void WallpaperDb::runMetadataJob(const MetadataJob& job) { + struct CacheResult { + QString path; + QByteArray cache; + QByteArray poster; + QByteArray scheme; + }; + + const QSize cacheSize = m_cacheSize; + const bool isVideoJob = job.isVideo; + + QElapsedTimer clock; + clock.start(); + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, + &QFutureWatcher::finished, + this, + [this, watcher, clock, isVideoJob]() { + const auto result = watcher->result(); + + qCInfo( + dbLog, + "WallpaperDb: %s produced %d bytes of cache in %dms", + result.path.toUtf8().constData(), + int(result.cache.size()), + int(clock.elapsed())); + + qCInfo( + dbLog, + "WallpaperDb: %s poster %d bytes", + result.path.toUtf8().constData(), + int(result.poster.size())); + + if (isVideoJob && result.poster.isEmpty()) { + qWarning(dbLog) + << "WallpaperDb: no poster produced for" << result.path; + } + + if (QSqlDatabase::contains(m_uuid)) { + const auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = ?, scheme_thumb = ?," + " poster = ? WHERE path = ?"); + query.addBindValue(result.cache); + query.addBindValue(result.scheme); + query.addBindValue(result.poster); + query.addBindValue(result.path); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the cache for" + << result.path << query.lastError(); + } + } + } + + if (!result.cache.isEmpty()) { + if (auto* entry = m_byPath.value(result.path)) { + entry->setThumbnailPath(thumbnailUrl(result.path)); + } + + emit cacheStored(result.path); + } + + m_jobRunning = false; + rebuildEntries(); + watcher->deleteLater(); + }); + + watcher->setFuture(QtConcurrent::run([job, cacheSize]() { + CacheResult result; + result.path = job.path; + + QByteArray cache = job.existingCache; + if (cache.isEmpty()) { + cache = job.isVideo ? videoCacheBytes(job.path, cacheSize) + : imageCacheBytes(job.path, cacheSize); + } + + QByteArray poster = job.existingPoster; + if (poster.isEmpty()) { + poster = + job.isVideo ? videoPosterBytes(job.path, cacheSize) : cache; + } + + result.cache = cache; + result.poster = poster; + result.scheme = cache.isEmpty() ? QByteArray() + : schemeThumbBytes(cache, 128); + + return result; + })); +} + +void WallpaperDb::rebuildEntries() { + QList next; + next.reserve(m_byPath.size()); + + for (auto* entry : std::as_const(m_byPath)) { + if (entryAllowed(entry)) { + next.append(entry); + } + } + + std::sort( + next.begin(), + next.end(), + [](const WallpaperEntry* a, const WallpaperEntry* b) { + return a->relativePath() < b->relativePath(); + }); + + if (next == m_entries) { + return; + } + + m_entries = next; + emit entriesChanged(); +} + +bool WallpaperDb::entryAllowed(const WallpaperEntry* entry) const { + if (!entry->isVideo()) { + return true; + } + + if (!m_requireHardwareCodec) { + return true; + } + + return entry->hardware(); +} + +QVariantMap WallpaperDb::crop(const QString& path, const QString& screen) const { + const auto pathCrops = m_crops.constFind(path); + if (pathCrops == m_crops.constEnd()) return {}; + return pathCrops.value().value(screen).toMap(); +} + +void WallpaperDb::setCrop( + const QString& path, const QString& screen, const QVariantMap& crop) { + if (path.isEmpty() || screen.isEmpty()) return; + + QVariantMap& pathCrops = m_crops[path]; + pathCrops[screen] = crop; + storeCropJson(path); + emit cropsChanged(path); +} + +void WallpaperDb::loadCrop(const QString& path, const QString& json) { + if (json.isEmpty()) { + m_crops.remove(path); + return; + } + + const auto doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isObject()) { + m_crops.remove(path); + return; + } + + m_crops[path] = doc.object().toVariantMap(); +} + +void WallpaperDb::storeCropJson(const QString& path) { + const auto db = QSqlDatabase::database(m_uuid, false); + if (!db.isOpen()) return; + + const auto pathCrops = m_crops.value(path); + const QByteArray json = + pathCrops.isEmpty() + ? QByteArray() + : QJsonDocument(QJsonObject::fromVariantMap(pathCrops)) + .toJson(QJsonDocument::Compact); + + QSqlQuery query(db); + query.prepare("UPDATE wallpapers SET crop = ? WHERE path = ?"); + query.addBindValue(json.isEmpty() ? QVariant() : QVariant(json)); + query.addBindValue(path); + if (!query.exec()) { + qWarning(dbLog) << "WallpaperDb: could not store the crop for" << path + << query.lastError(); + } +} + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperdb.hpp b/Plugins/ZShell/Models/wallpaperdb.hpp new file mode 100644 index 0000000..e41053d --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.hpp @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::models { + +class WallpaperImageProvider; + +class WallpaperEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE( + "WallpaperEntry instances can only be retrieved from a WallpaperDb") + + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString baseName READ baseName CONSTANT) + Q_PROPERTY(QString parentDir READ parentDir CONSTANT) + Q_PROPERTY(QString suffix READ suffix CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(bool isVideo READ isVideo CONSTANT) + Q_PROPERTY(bool hardware READ hardware NOTIFY hardwareChanged) + Q_PROPERTY( + QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged) + + public: + explicit WallpaperEntry( + const QString& path, + const QString& relativePath, + QObject* parent = nullptr); + + [[nodiscard]] QString path() const; + [[nodiscard]] QString relativePath() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString baseName() const; + [[nodiscard]] QString parentDir() const; + [[nodiscard]] QString suffix() const; + [[nodiscard]] qint64 size() const; + [[nodiscard]] bool isImage() const; + [[nodiscard]] bool isVideo() const; + [[nodiscard]] bool hardware() const; + [[nodiscard]] bool cached() const; + [[nodiscard]] QString thumbnailPath() const; + + void setRelativePath(const QString& path); + void setHardware(bool hardware); + void setThumbnailPath(const QString& path); + + signals: + void relativePathChanged(); + void hardwareChanged(); + void thumbnailPathChanged(); + + private: + const QFileInfo m_fileInfo; + + const QString m_path; + QString m_relativePath; + QString m_thumbnailPath; + bool m_hardware = false; +}; + +class WallpaperDb : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) + Q_PROPERTY( + QQmlListProperty entries READ entries + NOTIFY entriesChanged) + Q_PROPERTY( + QSize cacheSize READ cacheSize WRITE setCacheSize NOTIFY + cacheSizeChanged) + + public: + explicit WallpaperDb(QObject* parent = nullptr); + ~WallpaperDb() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + + [[nodiscard]] QQmlListProperty entries(); + + [[nodiscard]] QSize cacheSize() const; + void setCacheSize(const QSize& cacheSize); + + [[nodiscard]] static QString thumbnailUrl(const QString& path); + + Q_INVOKABLE QVariantMap crop(const QString& path, const QString& screen) + const; + Q_INVOKABLE void setCrop( + const QString& path, const QString& screen, const QVariantMap& crop); + + signals: + void pathChanged(); + void dbPathChanged(); + void requireHardwareCodecChanged(); + void entriesChanged(); + void cacheSizeChanged(); + + void cacheStored(const QString& path); + void cropsChanged(const QString& path); + + private: + struct FileEntry { + QFileInfo info; + bool hardware = false; + }; + + struct ScanResult { + QVector files; + QDir dir; + }; + + struct MetadataJob { + WallpaperEntry* entry; + QString path; + bool isVideo; + QByteArray existingCache; + QByteArray existingPoster; + }; + + void openDb(); + void registerProvider(); + void scanDir(); + void watchDir(const QString& dir); + void applyScan(const ScanResult& result); + void queueMetadata( + WallpaperEntry* entry, + bool isVideo, + const QByteArray& existingCache = {}, + const QByteArray& existingPoster = {}); + void processQueue(); + void runMetadataJob(const MetadataJob& job); + void rebuildEntries(); + + void loadCrop(const QString& path, const QString& json); + void storeCropJson(const QString& path); + + [[nodiscard]] bool entryAllowed(const WallpaperEntry* entry) const; + + QDir m_dir; + QString m_path; + QString m_dbPath; + QString m_uuid; + WallpaperImageProvider* m_provider = nullptr; + + bool m_requireHardwareCodec = false; + QSize m_cacheSize; + + QList m_entries; + QHash m_byPath; + + QHash m_crops; + + QVector m_queue; + QTimer m_queueTimer; + bool m_jobRunning = false; + + QFileSystemWatcher m_watcher; + QTimer m_watchTimer; +}; + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperutil.hpp b/Plugins/ZShell/Models/wallpaperutil.hpp new file mode 100644 index 0000000..447fc67 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperutil.hpp @@ -0,0 +1,398 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ZShell::models { +namespace { + +inline const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +inline bool hasVideoExtension(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +inline const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; + +inline bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + process.waitForFinished(5000); + + if (process.exitCode() != 0) { + return false; + } + + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); +} + +inline QSize coverSizeNoUpscale(const QSize& source, const QSize& fit) { + if (!source.isValid() || source.isEmpty() || !fit.isValid() || + fit.isEmpty()) { + return source; + } + + const QSize cover = source.scaled(fit, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && cover.height() <= source.height()) { + return cover; + } + + return source; +} + +inline QImage cleanImage(const QImage& src) { + QImage out(src.size(), QImage::Format_RGB888); + if (out.isNull()) { + return out; + } + + const auto rowBytes = + static_cast(std::min(src.bytesPerLine(), out.bytesPerLine())); + for (int y = 0; y < src.height(); ++y) { + std::memcpy(out.scanLine(y), src.constScanLine(y), rowBytes); + } + + return out; +} + +inline QByteArray imageCacheBytes(const QString& path, const QSize& fit) { + QImageReader reader(path); + reader.setAutoTransform(true); + + const QSize source = reader.size(); + const QSize target = coverSizeNoUpscale(source, fit); + if (target != source && !target.isEmpty()) { + reader.setScaledSize(target); + } + + QImage image = reader.read(); + if (image.isNull()) { + return {}; + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 90)) { + return {}; + } + + return bytes; +} + +inline QByteArray videoCacheBytes(const QString& src, const QSize& fit) { + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + if (!process.waitForFinished(15000)) { + process.kill(); + process.waitForFinished(1000); + } + return qMakePair(process.exitCode(), process.readAllStandardOutput()); + }; + + double duration = 0.0; + const auto probe = runProcess( + "ffprobe", + { + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + src, + }); + if (probe.first == 0) { + duration = QString::fromUtf8(probe.second).trimmed().toDouble(); + } + + QSize source; + const auto resProbe = runProcess( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + src, + }); + if (resProbe.first == 0) { + const auto parts = + QString::fromUtf8(resProbe.second).trimmed().split('x'); + if (parts.size() == 2) { + source = QSize(parts[0].toInt(), parts[1].toInt()); + } + } + + const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) + : 8; + + QSize target = coverSizeNoUpscale(source, fit); + if (target.isEmpty()) { + target = fit; + } + + target.setWidth(target.width() & ~1); + target.setHeight(target.height() & ~1); + + const QString scale = + QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2") + .arg(target.width()) + .arg(target.height()); + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return {}; + } + + int bestIndex = -1; + double bestChroma = -1.0; + double bestLuma = -1.0; + + for (int i = 0; i < samples; ++i) { + const double frac = duration > 0 ? (i + 0.5) / samples : 0.0; + const QString seek = duration > 0 + ? QString::number(frac * duration, 'f', 3) + : QStringLiteral("0"); + + const QString framePath = + tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i)); + + runProcess( + "ffmpeg", + { + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + src, + "-frames:v", + "1", + "-vf", + scale, + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + framePath, + }); + + QImage frame(framePath); + if (frame.isNull()) { + continue; + } + + frame = frame.convertToFormat(QImage::Format_RGB888); + + double chroma = 0.0; + double luma = 0.0; + int count = 0; + for (int y = 0; y < frame.height(); ++y) { + const auto* line = frame.constScanLine(y); + for (int x = 0; x < frame.width(); ++x) { + const int r = line[x * 3]; + const int g = line[x * 3 + 1]; + const int b = line[x * 3 + 2]; + chroma += std::max({r, g, b}) - std::min({r, g, b}); + luma += r + g + b; + ++count; + } + } + + const int n = std::max(1, count); + chroma /= n; + luma /= 3.0 * n; + + if (chroma > bestChroma + 0.5 || + (chroma > bestChroma - 0.5 && luma > bestLuma)) { + bestChroma = chroma; + bestLuma = luma; + bestIndex = i; + } + } + + if (bestIndex < 0) { + return {}; + } + + QFile frame(tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex))); + if (!frame.open(QIODevice::ReadOnly)) { + return {}; + } + + return frame.readAll(); +} + +inline QByteArray videoPosterBytes(const QString& src, const QSize& fit) { + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + if (!process.waitForFinished(15000)) { + process.kill(); + process.waitForFinished(1000); + } + return qMakePair(process.exitCode(), process.readAllStandardOutput()); + }; + + QSize source; + const auto resProbe = runProcess( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + src, + }); + if (resProbe.first == 0) { + const auto parts = + QString::fromUtf8(resProbe.second).trimmed().split('x'); + if (parts.size() == 2) { + source = QSize(parts[0].toInt(), parts[1].toInt()); + } + } + + QSize target = coverSizeNoUpscale(source, fit); + if (target.isEmpty()) { + target = fit; + } + + target.setWidth(target.width() & ~1); + target.setHeight(target.height() & ~1); + + const QString scale = + QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2") + .arg(target.width()) + .arg(target.height()); + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return {}; + } + + const QString framePath = tmpDir.filePath(QStringLiteral("poster.jpg")); + + runProcess( + "ffmpeg", + { + "-hide_banner", + "-loglevel", + "error", + "-ss", + "0", + "-i", + src, + "-frames:v", + "1", + "-vf", + scale, + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + framePath, + }); + + QFile frame(framePath); + if (!frame.open(QIODevice::ReadOnly)) { + return {}; + } + + return frame.readAll(); +} + +inline QByteArray schemeThumbBytes(const QByteArray& source, int maxSide) { + QImage image = QImage::fromData(source, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (image.width() > maxSide || image.height() > maxSide) { + image = image.scaled( + maxSide, maxSide, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 85)) { + return {}; + } + + return bytes; +} + +} // namespace + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Services/dropimporter.cpp b/Plugins/ZShell/Services/dropimporter.cpp index 167e941..3cac93f 100644 --- a/Plugins/ZShell/Services/dropimporter.cpp +++ b/Plugins/ZShell/Services/dropimporter.cpp @@ -93,7 +93,7 @@ int DropImporter::importAttachments( saveBytes(id, rawImage); } else if (const QString src = firstImgSrc(html); !src.isEmpty()) { if (src.startsWith(QLatin1String("data:"), Qt::CaseInsensitive)) { - const int comma = src.indexOf(QLatin1Char(',')); + const qsizetype comma = src.indexOf(QLatin1Char(',')); if (comma < 0) { failWith(id, tr("Malformed data URI")); } else { diff --git a/Plugins/ZShell/Wallpaper/CMakeLists.txt b/Plugins/ZShell/Wallpaper/CMakeLists.txt new file mode 100644 index 0000000..2a3186a --- /dev/null +++ b/Plugins/ZShell/Wallpaper/CMakeLists.txt @@ -0,0 +1,70 @@ +wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") +wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") +wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") +wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") + +qml_module(ZShell-Wallpaper + URI ZShell.Wallpaper + INCLUDE_PREFIX wallpaper + SOURCES + wallpaperservice.hpp wallpaperservice.cpp + wallpaperobject.hpp wallpaperobject.cpp + wallpapersurface.hpp wallpapersurface.cpp + wallpaperimage.hpp wallpaperimage.cpp + wallpapercache.hpp wallpapercache.cpp + videotranscoder.hpp videotranscoder.cpp + videodecoder.hpp videodecoder.cpp + nvidiaplayer.hpp nvidiaplayer.cpp + vaapiplayer.hpp vaapiplayer.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Sql + Qt::Core + Qt::GuiPrivate + Qt::WaylandClient + Qt::WaylandClientPrivate + wlp-layer-shell + wlp-xdg-shell + wlp-viewporter + wlp-fractional-scale + PkgConfig::EGL + PkgConfig::WAYLAND_EGL + PkgConfig::GL + PkgConfig::MPV + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + ZShell-config +) + +target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_FFMPEG=1) + +if(LIBVA_FOUND) + target_link_libraries(ZShell-Wallpaper PRIVATE PkgConfig::LIBVA) + target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_VAAPI=1) +endif() + +if(CUDAToolkit_FOUND) + target_link_libraries(ZShell-Wallpaper PRIVATE CUDA::cuda_driver) + target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_VIDEO=1) +endif() + +qt_add_resources(ZShell-Wallpaper "wallpaper_shaders" + PREFIX "/" + FILES + shaders/wallpaper.vert + shaders/wallpaper.frag + shaders/wallpaper_video.frag + shaders/wallpaper_trans_bowtie.frag + shaders/wallpaper_trans_fade.frag + shaders/wallpaper_trans_linear_blur.frag + shaders/wallpaper_trans_window_slices.frag + shaders/wallpaper_trans_wipe_right.frag + shaders/wallpaper_trans_wipe_down.frag + shaders/wallpaper_trans_tiles_wave.frag + shaders/wallpaper_trans_split_slide_in_horizontal.frag + shaders/wallpaper_trans_hexagonalize.frag + shaders/wallpaper_trans_doom_melt.frag +) diff --git a/Plugins/ZShell/Wallpaper/nvidiaplayer.cpp b/Plugins/ZShell/Wallpaper/nvidiaplayer.cpp new file mode 100644 index 0000000..1a2463c --- /dev/null +++ b/Plugins/ZShell/Wallpaper/nvidiaplayer.cpp @@ -0,0 +1,567 @@ +#ifdef ZHAVE_VIDEO + +#include "nvidiaplayer.hpp" + +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include +#include +#include +#include + +#include +#include +#include + +#include + +extern "C" CUresult cuGraphicsGLRegisterImage( + CUgraphicsResource* resource, + GLuint image, + GLenum target, + unsigned int flags); + +Q_LOGGING_CATEGORY(videoLog, "ZShell.Internal.wallpaper") + +namespace ZShell::wallpaper { + +namespace { + +enum AVPixelFormat chooseFormat( + AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + (void)ctx; + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + if (*p == AV_PIX_FMT_CUDA) return AV_PIX_FMT_CUDA; + return pix_fmts[0]; +} + +class CudaCtx { + public: + explicit CudaCtx(CUcontext ctx) : m_ctx(ctx) { + if (m_ctx) cuCtxPushCurrent(m_ctx); + } + ~CudaCtx() { + if (m_ctx) cuCtxPopCurrent(&m_prev); + } + + private: + CUcontext m_ctx; + CUcontext m_prev = nullptr; +}; + +} // namespace + +NvidiaPlayer::NvidiaPlayer(EGLDisplay display) { + (void)display; +} + +NvidiaPlayer::~NvidiaPlayer() { + stop(); + + std::lock_guard lock(m_mtx); + + CUcontext prev = nullptr; + if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); + + if (m_pending) { + av_frame_unref(m_pending); + av_frame_free(&m_pending); + } + if (m_displayed) { + av_frame_unref(m_displayed); + av_frame_free(&m_displayed); + } + + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + + if (m_cuCtx) cuCtxPopCurrent(&prev); + + if (m_cuCtx) { + cuCtxDestroy(m_cuCtx); + m_cuCtx = nullptr; + } + m_texSize = QSize(); +} + +bool NvidiaPlayer::available() { + if (cuInit(0) != CUDA_SUCCESS) return false; + CUdevice dev = 0; + return cuDeviceGet(&dev, 0) == CUDA_SUCCESS; +} + +bool NvidiaPlayer::openDecoder(const QString& path) { + if (avformat_open_input( + &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { + qCWarning( + videoLog, + "VideoPlayer: could not open %s", + path.toUtf8().constData()); + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not read stream info"); + return false; + } + + m_streamIndex = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + m_streamIndex = int(i); + break; + } + if (m_streamIndex < 0) { + qCWarning(videoLog, "VideoPlayer: no video stream"); + return false; + } + + AVCodecParameters* par = m_fmt->streams[m_streamIndex]->codecpar; + const AVCodec* codec = avcodec_find_decoder(par->codec_id); + const AVCodec* hwcodec = nullptr; + void* it = nullptr; + while (const AVCodec* c = av_codec_iterate(&it)) { + if (c->id != par->codec_id || !av_codec_is_decoder(c)) continue; + for (int i = 0; + const AVCodecHWConfig* cfg = avcodec_get_hw_config(c, i); + ++i) + if (cfg->device_type == AV_HWDEVICE_TYPE_CUDA && + (cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) { + hwcodec = c; + break; + } + if (hwcodec) break; + } + if (hwcodec) codec = hwcodec; + if (!codec) { + qCWarning( + videoLog, + "VideoPlayer: no decoder for codec id %d", + int(par->codec_id)); + return false; + } + + m_codec = avcodec_alloc_context3(codec); + if (!m_codec) return false; + if (avcodec_parameters_to_context(m_codec, par) < 0) { + qCWarning(videoLog, "VideoPlayer: could not copy codec params"); + return false; + } + + m_hwDeviceCtx = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_CUDA); + if (!m_hwDeviceCtx) { + qCWarning( + videoLog, "VideoPlayer: could not allocate cuda hwdevice context"); + return false; + } + AVCUDADeviceContext* cudaHw = reinterpret_cast( + reinterpret_cast(m_hwDeviceCtx->data)->hwctx); + if (m_cuCtx) cudaHw->cuda_ctx = m_cuCtx; + if (av_hwdevice_ctx_init(m_hwDeviceCtx) < 0) { + qCWarning( + videoLog, "VideoPlayer: could not create cuda hwdevice context"); + av_buffer_unref(&m_hwDeviceCtx); + return false; + } + m_codec->hw_device_ctx = av_buffer_ref(m_hwDeviceCtx); + m_codec->get_format = chooseFormat; + + if (avcodec_open2(m_codec, codec, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not open codec"); + return false; + } + + return true; +} + +void NvidiaPlayer::closeDecoder() { + if (m_codec) { + avcodec_free_context(&m_codec); + m_codec = nullptr; + } + if (m_hwDeviceCtx) { + av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + } + if (m_fmt) { + avformat_close_input(&m_fmt); + m_fmt = nullptr; + } + m_streamIndex = -1; +} + +void NvidiaPlayer::decodeLoop(const QString& path, uint32_t generation) { + if (!m_threadRunning.load(std::memory_order_acquire) || + m_generation.load(std::memory_order_acquire) != generation) + return; + + if (!openDecoder(path)) { + m_threadRunning.store(false); + return; + } + + AVRational fr = m_fmt->streams[m_streamIndex]->avg_frame_rate; + if (fr.num <= 0 || fr.den <= 0) fr = m_codec->framerate; + if (fr.num <= 0 || fr.den <= 0) fr = AVRational{30, 1}; + const int64_t intervalUs = + av_rescale_q(1, AVRational{fr.den, fr.num}, AVRational{1, 1000000}); + + AVFrame* frame = av_frame_alloc(); + if (!frame) { + m_threadRunning.store(false); + return; + } + + AVPacket pkt{}; + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation) { + int ret = av_read_frame(m_fmt, &pkt); + if (ret == AVERROR_EOF) { + if (avformat_seek_file( + m_fmt, m_streamIndex, INT64_MIN, 0, INT64_MAX, 0) < 0) { + qCWarning(videoLog, "VideoPlayer: loop seek failed; stopping"); + break; + } + avcodec_flush_buffers(m_codec); + continue; + } + if (ret < 0) { + qCWarning(videoLog, "VideoPlayer: read error; stopping"); + break; + } + if (pkt.stream_index != m_streamIndex) { + av_packet_unref(&pkt); + continue; + } + + if (avcodec_send_packet(m_codec, &pkt) < 0) { + av_packet_unref(&pkt); + continue; + } + av_packet_unref(&pkt); + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation && + avcodec_receive_frame(m_codec, frame) == 0) { + bool wasEmpty = false; + { + std::lock_guard lock(m_mtx); + if (!m_pending) m_pending = av_frame_alloc(); + av_frame_unref(m_pending); + if (av_frame_ref(m_pending, frame) >= 0) { + wasEmpty = !m_haveFrame; + m_haveFrame = true; + } + } + if (wasEmpty && m_readyCb) m_readyCb(); + if (m_frameCb) m_frameCb(); + av_frame_unref(frame); + } + + std::this_thread::sleep_for(std::chrono::microseconds(intervalUs)); + } + + av_frame_free(&frame); + { + CudaCtx ctxGuard(m_cuCtx); + closeDecoder(); + } + m_threadRunning.store(false); +} + +bool NvidiaPlayer::start(const QString& path) { + if (!ensureCudaContext()) return false; + + const uint32_t generation = + m_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + m_threadRunning.store(true); + + std::thread previous = std::move(m_thread); + m_thread = std::thread( + [this, path, generation, previous = std::move(previous)]() mutable { + if (previous.joinable()) previous.join(); + closeDecoder(); + decodeLoop(path, generation); + }); + + return true; +} + +void NvidiaPlayer::stop() { + m_generation.fetch_add(1, std::memory_order_acq_rel); + m_threadRunning.store(false); + if (m_thread.joinable()) m_thread.join(); + + std::lock_guard lock(m_mtx); + CudaCtx ctxGuard(m_cuCtx); + + closeDecoder(); + + if (m_pending) { + av_frame_unref(m_pending); + m_pending = nullptr; + } + if (m_displayed) { + av_frame_unref(m_displayed); + m_displayed = nullptr; + } + m_haveFrame = false; + m_size = QSize(); +} + +void NvidiaPlayer::deleteTexture() { + std::lock_guard lock(m_mtx); + CUcontext prev = nullptr; + if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + if (m_cuCtx) cuCtxPopCurrent(&prev); + m_texSize = QSize(); +} + +bool NvidiaPlayer::ensureCudaContext() { + if (m_cuCtx) return true; + + if (cuInit(0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuInit failed"); + return false; + } + CUdevice dev = 0; + if (cuDeviceGet(&dev, 0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuDeviceGet failed"); + return false; + } + if (cuCtxCreate_v4(&m_cuCtx, nullptr, 0, dev) != CUDA_SUCCESS) { + m_cuCtx = nullptr; + qCWarning(videoLog, "VideoPlayer: cuCtxCreate failed"); + return false; + } + CUcontext popped = nullptr; + cuCtxPopCurrent(&popped); + return true; +} + +bool NvidiaPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { + if (!ensureCudaContext()) return false; + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + + const QSize sz = QSize(int(w), int(h)); + if (m_texSize == sz && m_texComp == compBytes && m_texY != 0) { + cuCtxPopCurrent(&prev); + return true; + } + + if (m_resY) cuGraphicsUnregisterResource(m_resY); + if (m_resUV) cuGraphicsUnregisterResource(m_resUV); + m_resY = m_resUV = nullptr; + if (m_texY) glDeleteTextures(1, &m_texY); + if (m_texUV) glDeleteTextures(1, &m_texUV); + m_texY = m_texUV = 0; + + const bool wide = compBytes == 2; + auto makeTex = + [&](GLuint& tex, GLint internal, GLenum fmt, unsigned tw, unsigned th) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal, + GLint(tw), + GLint(th), + 0, + fmt, + wide ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE, + nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + makeTex(m_texY, wide ? GL_R16 : GL_R8, GL_RED, w, h); + makeTex(m_texUV, wide ? GL_RG16 : GL_RG8, GL_RG, w / 2, h / 2); + + CUresult r = cuGraphicsGLRegisterImage( + &m_resY, + m_texY, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + const char* es = nullptr; + cuGetErrorString(r, &es); + qCWarning( + videoLog, + "VideoPlayer: register luma tex failed (%s)", + es ? es : "?"); + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + r = cuGraphicsGLRegisterImage( + &m_resUV, + m_texUV, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: register chroma tex failed"); + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + + m_texSize = sz; + m_texComp = compBytes; + cuCtxPopCurrent(&prev); + return true; +} + +bool NvidiaPlayer::importCurrentFrame() { + AVFrame* frame = nullptr; + { + std::lock_guard lock(m_mtx); + if (!m_pending || m_pending->format == AV_PIX_FMT_NONE) return false; + frame = m_pending; + m_pending = nullptr; + } + + if (frame->format != AV_PIX_FMT_CUDA || !frame->hw_frames_ctx) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + videoLog, + "VideoPlayer: frame not on GPU (fmt=%s hw_frames=%d)", + av_get_pix_fmt_name(AVPixelFormat(frame->format)), + frame->hw_frames_ctx != nullptr); + av_frame_unref(frame); + return false; + } + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVPixelFormat sw = hf->sw_format; + const bool tenBit = sw == AV_PIX_FMT_P010LE || sw == AV_PIX_FMT_P010BE || + sw == AV_PIX_FMT_P012LE || sw == AV_PIX_FMT_P016LE; + const int compBytes = tenBit ? 2 : 1; + const size_t compSize = size_t(compBytes); + + const unsigned w = unsigned(frame->width); + const unsigned h = unsigned(frame->height); + if (w == 0 || h == 0) { + av_frame_unref(frame); + return false; + } + + size_t pitchY = size_t(frame->linesize[0]); + size_t pitchUV = frame->linesize[1] > 0 ? size_t(frame->linesize[1]) + : pitchY; + if (pitchY == 0) pitchY = size_t(w) * compSize; + + switch (frame->colorspace) { + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + m_matrix = 0; + break; + case AVCOL_SPC_BT709: + m_matrix = 1; + break; + default: + m_matrix = h >= 720 ? 1 : 0; + break; + } + m_range = frame->color_range == AVCOL_RANGE_JPEG ? 1 : 0; + m_size = QSize(int(w), int(h)); + + if (!ensureTextures(w, h, compBytes)) { + av_frame_unref(frame); + return false; + } + + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + CUgraphicsResource res[2] = {m_resY, m_resUV}; + CUresult r = cuGraphicsMapResources(2, res, nullptr); + if (r != CUDA_SUCCESS) { + cuCtxPopCurrent(&prev); + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuGraphicsMapResources failed"); + return false; + } + + CUarray arrY = nullptr, arrUV = nullptr; + cuGraphicsSubResourceGetMappedArray(&arrY, m_resY, 0, 0); + cuGraphicsSubResourceGetMappedArray(&arrUV, m_resUV, 0, 0); + + const size_t dstPitch = size_t(w) * compSize; + CUDA_MEMCPY2D mc{}; + mc.srcMemoryType = CU_MEMORYTYPE_DEVICE; + mc.dstMemoryType = CU_MEMORYTYPE_ARRAY; + mc.srcDevice = CUdeviceptr(frame->data[0]); + mc.srcPitch = pitchY; + mc.dstArray = arrY; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchY, dstPitch); + mc.Height = h; + r = cuMemcpy2D(&mc); + if (r == CUDA_SUCCESS) { + mc.srcDevice = CUdeviceptr(frame->data[1]); + mc.srcPitch = pitchUV; + mc.dstArray = arrUV; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchUV, dstPitch); + mc.Height = h / 2; + r = cuMemcpy2D(&mc); + } + + cuGraphicsUnmapResources(2, res, nullptr); + cuCtxPopCurrent(&prev); + + if (r != CUDA_SUCCESS) { + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuMemcpy2D failed"); + return false; + } + + { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + } + + return true; +} + +} // namespace ZShell::wallpaper + +#endif diff --git a/Plugins/ZShell/Wallpaper/nvidiaplayer.hpp b/Plugins/ZShell/Wallpaper/nvidiaplayer.hpp new file mode 100644 index 0000000..823876e --- /dev/null +++ b/Plugins/ZShell/Wallpaper/nvidiaplayer.hpp @@ -0,0 +1,78 @@ +#pragma once + +#ifdef ZHAVE_VIDEO + +#include "videodecoder.hpp" + +#include + +#include + +#include +#include +#include + +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +} + +namespace ZShell::wallpaper { + +class NvidiaPlayer : public VideoDecoder { + public: + explicit NvidiaPlayer(EGLDisplay display); + ~NvidiaPlayer() override; + + [[nodiscard]] static bool available(); + + bool start(const QString& path) override; + void stop() override; + bool importCurrentFrame() override; + void deleteTexture() override; + + [[nodiscard]] const char* backendName() const override { return "nvidia"; } + + private: + void decodeLoop(const QString& path, uint32_t generation); + bool openDecoder(const QString& path); + void closeDecoder(); + + bool ensureTextures(unsigned w, unsigned h, int compBytes); + + bool ensureCudaContext(); + + std::thread m_thread; + std::atomic m_threadRunning{false}; + std::atomic m_generation{0}; + + mutable std::mutex m_mtx; + AVFrame* m_pending = nullptr; + bool m_haveFrame = false; + + AVFrame* m_displayed = nullptr; + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_codec = nullptr; + AVBufferRef* m_hwDeviceCtx = nullptr; + int m_streamIndex = -1; + + CUcontext m_cuCtx = nullptr; + CUgraphicsResource m_resY = nullptr; + CUgraphicsResource m_resUV = nullptr; + QSize m_texSize; + int m_texComp = 1; +}; + +} // namespace ZShell::wallpaper + +#endif // ZHAVE_VIDEO diff --git a/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml b/Plugins/ZShell/Wallpaper/protocols/wlr-layer-shell-unstable-v1.xml similarity index 100% rename from Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml rename to Plugins/ZShell/Wallpaper/protocols/wlr-layer-shell-unstable-v1.xml diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper.frag new file mode 100644 index 0000000..b0db80a --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper.frag @@ -0,0 +1,11 @@ +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +uniform float alpha; +out vec4 outColor; + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec4 c = texture(source, crop.xy + p * crop.zw); + outColor = vec4(c.rgb, c.a * alpha); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper.vert b/Plugins/ZShell/Wallpaper/shaders/wallpaper.vert new file mode 100644 index 0000000..8529808 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper.vert @@ -0,0 +1,4 @@ +void main() { + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_bowtie.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_bowtie.frag new file mode 100644 index 0000000..3806896 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_bowtie.frag @@ -0,0 +1,106 @@ +// Bow-tie wipe transition adapted from gl-transitions.com +// Author: huynx (MIT) +// Original: two triangles grow from the top and bottom edges into a bow tie +// that reveals the incoming image. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +float check(vec2 p1, vec2 p2, vec2 p3) { + return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); +} + +bool pointInTriangle(vec2 pt, vec2 p1, vec2 p2, vec2 p3) { + bool b1 = check(pt, p1, p2) < 0.0; + bool b2 = check(pt, p2, p3) < 0.0; + bool b3 = check(pt, p3, p1) < 0.0; + return (b1 == b2) && (b2 == b3); +} + +bool in_top_triangle(vec2 p) { + vec2 vertex1 = vec2(0.5, progress); + vec2 vertex2 = vec2(0.5 - progress, 0.0); + vec2 vertex3 = vec2(0.5 + progress, 0.0); + return pointInTriangle(p, vertex1, vertex2, vertex3); +} + +bool in_bottom_triangle(vec2 p) { + vec2 vertex1 = vec2(0.5, 1.0 - progress); + vec2 vertex2 = vec2(0.5 - progress, 1.0); + vec2 vertex3 = vec2(0.5 + progress, 1.0); + return pointInTriangle(p, vertex1, vertex2, vertex3); +} + +float blur_edge(vec2 bot1, vec2 bot2, vec2 top, vec2 testPt) { + vec2 lineDir = bot1 - top; + vec2 perpDir = vec2(lineDir.y, -lineDir.x); + vec2 dirToPt1 = bot1 - testPt; + float dist1 = abs(dot(normalize(perpDir), dirToPt1)); + + lineDir = bot2 - top; + perpDir = vec2(lineDir.y, -lineDir.x); + dirToPt1 = bot2 - testPt; + float min_dist = min(abs(dot(normalize(perpDir), dirToPt1)), dist1); + + if (min_dist < 0.005) + return min_dist / 0.005; + return 1.0; +} + +vec4 transition(vec2 uv) { + if (in_top_triangle(uv)) { + if (progress < 0.1) + return getFromColor(uv); + + if (uv.y < 0.5) { + vec2 vertex1 = vec2(0.5, progress); + vec2 vertex2 = vec2(0.5 - progress, 0.0); + vec2 vertex3 = vec2(0.5 + progress, 0.0); + return mix( + getFromColor(uv), + getToColor(uv), + blur_edge(vertex2, vertex3, vertex1, uv)); + } + if (progress > 0.0) + return getToColor(uv); + return getFromColor(uv); + } + + if (in_bottom_triangle(uv)) { + if (uv.y >= 0.5) { + vec2 vertex1 = vec2(0.5, 1.0 - progress); + vec2 vertex2 = vec2(0.5 - progress, 1.0); + vec2 vertex3 = vec2(0.5 + progress, 1.0); + return mix( + getFromColor(uv), + getToColor(uv), + blur_edge(vertex2, vertex3, vertex1, uv)); + } + return getFromColor(uv); + } + + return getFromColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_doom_melt.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_doom_melt.frag new file mode 100644 index 0000000..5b428cd --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_doom_melt.frag @@ -0,0 +1,72 @@ +// Doom-style melt wipe, from gl-transitions.com +// Author: Zeh Fernando (MIT) +// Description: the outgoing image melts down in uneven columns, like the Doom +// screen wipe. Fully procedural (no displacement texture). +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). Optional +// gl-transitions params are fixed here: bars = 30, amplitude = 2.0, +// noise = 0.1, frequency = 0.5, dripScale = 0.5. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const int bars = 30; +const float amplitude = 2.0; +const float noise = 0.1; +const float frequency = 0.5; +const float dripScale = 0.5; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +float rand(int num) { + return fract(mod(float(num) * 67123.313, 12.0) * sin(float(num) * 10.3) * + cos(float(num))); +} + +float wave(int num) { + float fn = float(num) * frequency * 0.1 * float(bars); + return cos(fn * 0.5) * cos(fn * 0.13) * sin((fn + 10.0) * 0.3) / 2.0 + 0.5; +} + +float drip(int num) { + return sin(float(num) / float(bars - 1) * 3.141592) * dripScale; +} + +float pos(int num) { + return (noise == 0.0 ? wave(num) + : mix(wave(num), rand(num), noise)) + + (dripScale == 0.0 ? 0.0 : drip(num)); +} + +vec4 transition(vec2 uv) { + int bar = int(uv.x * float(bars)); + float scale = 1.0 + pos(bar) * amplitude; + float phase = progress * scale; + float posY = uv.y; + + if (phase + posY < 1.0) { + return getFromColor(vec2(uv.x, uv.y + phase)); + } + return getToColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_fade.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_fade.frag new file mode 100644 index 0000000..34a4fce --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_fade.frag @@ -0,0 +1,39 @@ +// Fade (crossfade) transition, from gl-transitions.com. +// +// Ported to ZShell's own GLSL ABI (same contract as wallpaper_trans_bowtie.frag): +// version/precision header is prepended by WallpaperService::shaderSource, the +// fullscreen triangle comes from shaders/wallpaper.vert, and getFromColor / +// getToColor sample with the crop + per-layer flip convention. +// +// uniforms: samplers `from`/`to`, vec2 size, vec4 fromCrop, vec4 toCrop, +// float fromFlip, float toFlip, float progress (0..1). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float fromFlip; +uniform float toFlip; +uniform float progress; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_hexagonalize.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_hexagonalize.frag new file mode 100644 index 0000000..276b777 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_hexagonalize.frag @@ -0,0 +1,101 @@ +// Hexagonalize crossfade, from gl-transitions.com +// Author: Fernando Kuteken (MIT) +// Description: both images turn into hexagons, crossfade, then sharpen again. +// Hexagonal math from http://www.redblobgames.com/grids/hexagons/ +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). The +// gl-transitions `ratio` contextual value is derived from `size`. Optional +// params are fixed here: steps = 50, horizontalHexagons = 20. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const int steps = 50; +const float horizontalHexagons = 20.0; +float ratio; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +struct Hexagon { + float q; + float r; + float s; +}; + +Hexagon createHexagon(float q, float r) { + Hexagon hex; + hex.q = q; + hex.r = r; + hex.s = -q - r; + return hex; +} + +Hexagon roundHexagon(Hexagon hex) { + float q = floor(hex.q + 0.5); + float r = floor(hex.r + 0.5); + float s = floor(hex.s + 0.5); + + float deltaQ = abs(q - hex.q); + float deltaR = abs(r - hex.r); + float deltaS = abs(s - hex.s); + + if (deltaQ > deltaR && deltaQ > deltaS) + q = -r - s; + else if (deltaR > deltaS) + r = -q - s; + else + s = -q - r; + + return createHexagon(q, r); +} + +Hexagon hexagonFromPoint(vec2 point, float size) { + point.y /= ratio; + point = (point - 0.5) / size; + + float q = (sqrt(3.0) / 3.0) * point.x + (-1.0 / 3.0) * point.y; + float r = 0.0 * point.x + 2.0 / 3.0 * point.y; + + Hexagon hex = createHexagon(q, r); + return roundHexagon(hex); +} + +vec2 pointFromHexagon(Hexagon hex, float size) { + float x = (sqrt(3.0) * hex.q + (sqrt(3.0) / 2.0) * hex.r) * size + 0.5; + float y = (0.0 * hex.q + (3.0 / 2.0) * hex.r) * size + 0.5; + return vec2(x, y * ratio); +} + +vec4 transition(vec2 uv) { + float dist = 2.0 * min(progress, 1.0 - progress); + dist = steps > 0 ? ceil(dist * float(steps)) / float(steps) : dist; + + float size = (sqrt(3.0) / 3.0) * dist / horizontalHexagons; + + vec2 point = dist > 0.0 ? pointFromHexagon(hexagonFromPoint(uv, size), size) + : uv; + + return mix(getFromColor(point), getToColor(point), progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + ratio = size.x / size.y; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_linear_blur.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_linear_blur.frag new file mode 100644 index 0000000..00d8a25 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_linear_blur.frag @@ -0,0 +1,58 @@ +// Linear blur crossfade, from gl-transitions.com +// Author: gre (MIT) +// Description: a crossfade that passes through a directional blur. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag): the version +// header is prepended by WallpaperService::shaderSource, the fullscreen +// triangle comes from wallpaper.vert, and getFromColor/getToColor apply the +// crop + flip convention. `uv` is bottom-left origin per gl-transitions. +// +// Optional gl-transitions params are fixed here: intensity = 0.1. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const float intensity = 0.1; +const int passes = 6; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + vec4 c1 = vec4(0.0); + vec4 c2 = vec4(0.0); + + float disp = intensity * (0.5 - distance(0.5, progress)); + for (int xi = 0; xi < passes; xi++) { + float x = float(xi) / float(passes) - 0.5; + for (int yi = 0; yi < passes; yi++) { + float y = float(yi) / float(passes) - 0.5; + vec2 v = vec2(x, y); + c1 += getFromColor(uv + disp * v); + c2 += getToColor(uv + disp * v); + } + } + c1 /= float(passes * passes); + c2 /= float(passes * passes); + return mix(c1, c2, progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_split_slide_in_horizontal.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_split_slide_in_horizontal.frag new file mode 100644 index 0000000..9f6b535 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_split_slide_in_horizontal.frag @@ -0,0 +1,49 @@ +// Split slide-in (horizontal), from gl-transitions.com +// Author: OllyOllyOlly (MIT) +// Description: the incoming image slides in as two halves moving horizontally +// in opposite directions. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: reverse = false. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const vec2 boundMin = vec2(0.0, 0.0); +const vec2 boundMax = vec2(1.0, 1.0); + +bool inBounds(vec2 p) { + return all(lessThan(boundMin, p)) && all(lessThan(p, boundMax)); +} + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + float modifier = 1.0; + vec2 toP = (uv.y > 0.5) + ? vec2((uv.x - (progress * modifier)) + modifier, uv.y) + : vec2((uv.x + (progress * modifier)) - modifier, uv.y); + + return inBounds(toP) ? getToColor(toP) : getFromColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_tiles_wave.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_tiles_wave.frag new file mode 100644 index 0000000..5aacf50 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_tiles_wave.frag @@ -0,0 +1,89 @@ +// Tiles wave reveal, from gl-transitions.com +// Author: numb3r23 (MIT) +// Description: the incoming image is revealed tile by tile in a diagonal wave. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: tileCount = (8, 8), +// flipX = true, flipY = false. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const ivec2 tileCount = ivec2(8, 8); +const bool flipX = true; +const bool flipY = false; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + vec2 tileSize = 1.0 / vec2(tileCount); + vec2 posInTile = fract(uv * vec2(tileCount)); + vec2 tileNum = floor(uv * vec2(tileCount)); + float countTiles = float(tileCount.x * tileCount.y); + + // Diagonal wave from bottom-left to top-right. + float offset = (tileNum.y + tileNum.x * float(tileCount.y)) / countTiles; + float timeOffset = clamp((progress - offset) * countTiles, 0.0, 0.5); + float sinTime = 1.0 - abs(cos(fract(timeOffset) * 3.1415926)); + + vec2 texC = posInTile; + + if (sinTime <= 0.5) { + if (flipX) { + if (texC.x < sinTime || texC.x > 1.0 - sinTime) + return getFromColor(uv); + texC.x = texC.x < 0.5 + ? (texC.x - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.x - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + } + if (flipY) { + if (texC.y < sinTime || texC.y > 1.0 - sinTime) + return getFromColor(uv); + texC.y = texC.y < 0.5 + ? (texC.y - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.y - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + } + vec2 globalUV = tileNum * tileSize + texC * tileSize; + return getFromColor(globalUV); + } else { + if (flipX) { + if (texC.x > sinTime || texC.x < 1.0 - sinTime) + return getToColor(uv); + texC.x = texC.x < 0.5 + ? (texC.x - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.x - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + texC.x = 1.0 - texC.x; + } + if (flipY) { + if (texC.y > sinTime || texC.y < 1.0 - sinTime) + return getToColor(uv); + texC.y = texC.y < 0.5 + ? (texC.y - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.y - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + texC.y = 1.0 - texC.y; + } + vec2 globalUV = tileNum * tileSize + texC * tileSize; + return getToColor(globalUV); + } +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_window_slices.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_window_slices.frag new file mode 100644 index 0000000..44d56c0 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_window_slices.frag @@ -0,0 +1,42 @@ +// Window slices wipe, from gl-transitions.com +// Author: gre (MIT) +// Description: the incoming image appears through vertical slices that widen. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: count = 10.0, +// smoothness = 0.5. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const float count = 10.0; +const float smoothness = 0.5; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + float pr = smoothstep(-smoothness, 0.0, uv.x - progress * (1.0 + smoothness)); + float s = step(pr, fract(count * uv.x)); + return mix(getFromColor(uv), getToColor(uv), s); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_down.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_down.frag new file mode 100644 index 0000000..fbab8d6 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_down.frag @@ -0,0 +1,34 @@ +// Hard-edged wipe from top to bottom, from gl-transitions.com +// Author: Jake Nelson (MIT) +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), step(1.0 - uv.y, progress)); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_right.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_right.frag new file mode 100644 index 0000000..51391b5 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_right.frag @@ -0,0 +1,34 @@ +// Hard-edged wipe from left to right, from gl-transitions.com +// Author: Jake Nelson (MIT) +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), step(0.0 + uv.x, progress)); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Wallpaper/shaders/wallpaper_video.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_video.frag new file mode 100644 index 0000000..9c5aa55 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/shaders/wallpaper_video.frag @@ -0,0 +1,105 @@ +uniform sampler2D luma; +uniform sampler2D chroma; +uniform vec2 size; +uniform vec2 srcSize; +uniform vec4 crop; +uniform float alpha; +uniform int matrix; +uniform int range; +uniform vec2 chromaOff; +out vec4 outColor; + +float noise(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +const float PI = 3.1415926535893; + +float sinc(float x) { + x *= PI; + return abs(x) < 1e-6 ? 1.0 : sin(x) / x; +} + +float lanczos(float x) { + if (abs(x) >= 3.0) + return 0.0; + return sinc(x) * sinc(x / 3.0); +} + +float hermite(float x) { + float a = abs(x); + if (a >= 1.0) + return 0.0; + return 1.0 - 3.0 * a * a + 2.0 * a * a * a; +} + +vec4 resample(sampler2D tex, vec2 plane, vec2 dest, vec2 uv) { + vec2 pt = 1.0 / plane; + float wx[6]; + float wy[6]; + vec2 base; + + int upx = dest.x >= plane.x ? 1 : 0; + int tx = upx == 1 ? 6 : 2; + float fsx = max(1.0, plane.x / dest.x); + float f = fract(uv.x * plane.x - 0.5); + base.x = uv.x - f * pt.x - pt.x * (float(tx) * 0.5 - 1.0); + float sx = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= tx) { wx[i] = 0.0; continue; } + float x = f - (float(i) - float(tx) * 0.5 + 1.0); + wx[i] = upx == 1 ? lanczos(x / fsx) : hermite(x / fsx); + sx += wx[i]; + } + if (sx != 0.0) + for (int i = 0; i < 6; ++i) wx[i] /= sx; + + int upy = dest.y >= plane.y ? 1 : 0; + int ty = upy == 1 ? 6 : 2; + float fsy = max(1.0, plane.y / dest.y); + f = fract(uv.y * plane.y - 0.5); + base.y = uv.y - f * pt.y - pt.y * (float(ty) * 0.5 - 1.0); + float sy = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= ty) { wy[i] = 0.0; continue; } + float y = f - (float(i) - float(ty) * 0.5 + 1.0); + wy[i] = upy == 1 ? lanczos(y / fsy) : hermite(y / fsy); + sy += wy[i]; + } + if (sy != 0.0) + for (int i = 0; i < 6; ++i) wy[i] /= sy; + + vec4 col = vec4(0.0); + for (int i = 0; i < 6; ++i) { + if (wx[i] == 0.0) + continue; + for (int j = 0; j < 6; ++j) { + if (wy[j] == 0.0) + continue; + vec2 c = base + vec2(float(i) * pt.x, float(j) * pt.y); + col += (wx[i] * wy[j]) * texture(tex, c); + } + } + return col; +} + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec2 uv = crop.xy + p * crop.zw; + float Yv = resample(luma, srcSize, size, uv).r; + vec2 ch = resample(chroma, srcSize * 0.5, size, uv + chromaOff).rg; + vec3 yuv; + if (range == 0) { + yuv = vec3((Yv - 16.0 / 255.0) / (219.0 / 255.0), + (ch.r - 128.0 / 255.0) / (224.0 / 255.0), + (ch.g - 128.0 / 255.0) / (224.0 / 255.0)); + } else { + yuv = vec3(Yv, ch.r - 0.5, ch.g - 0.5); + } + mat3 m = matrix == 1 + ? mat3(1.0, 1.0, 1.0, 0.0, -0.187324, 1.855600, 1.574800, -0.468124, 0.0) + : mat3(1.0, 1.0, 1.0, 0.0, -0.344136, 1.772000, 1.402000, -0.714136, 0.0); + vec3 rgb = m * yuv; + rgb += (noise(gl_FragCoord.xy) - 0.5) / 255.0; + outColor = vec4(clamp(rgb, 0.0, 1.0), alpha); +} diff --git a/Plugins/ZShell/Wallpaper/vaapiplayer.cpp b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp new file mode 100644 index 0000000..f4e6b4d --- /dev/null +++ b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp @@ -0,0 +1,662 @@ +#include "vaapiplayer.hpp" + +#ifdef ZHAVE_FFMPEG + +#define GL_GLEXT_PROTOTYPES + +#include +#include +#include + +#ifdef ZHAVE_VAAPI +#include +extern "C" { +#include +#include +#include +} +#ifndef DRM_FORMAT_MOD_INVALID +#define DRM_FORMAT_MOD_INVALID 0xffffffffffffffffULL +#endif +#endif + +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(vaapiLog, "ZShell.Internal.wallpaper") + +namespace ZShell::wallpaper { + +namespace { + +enum AVPixelFormat chooseFormat( + AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + (void)ctx; + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + if (*p == AV_PIX_FMT_VAAPI) return AV_PIX_FMT_VAAPI; + return pix_fmts[0]; +} + +#ifdef ZHAVE_VAAPI +using GLEGLImageTargetProc = void (*)(GLenum, EGLImageKHR); + +GLEGLImageTargetProc glEGLImageTarget() { + static GLEGLImageTargetProc fn = reinterpret_cast( + eglGetProcAddress("glEGLImageTargetTexture2DOES")); + return fn; +} + +// eglCreateImageKHR/eglDestroyImageKHR are EGL extension entry points. Fetch +// them through eglGetProcAddress rather than linking them directly, so the +// shared library does not carry a hard undefined reference to symbols libEGL +// may not export on every platform. +using PfnCreateImageKHR = EGLImageKHR (*)( + EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*); +using PfnDestroyImageKHR = EGLBoolean (*)(EGLDisplay, EGLImageKHR); + +PfnCreateImageKHR eglCreateImageProc() { + static PfnCreateImageKHR fn = reinterpret_cast( + eglGetProcAddress("eglCreateImageKHR")); + return fn; +} + +PfnDestroyImageKHR eglDestroyImageProc() { + static PfnDestroyImageKHR fn = reinterpret_cast( + eglGetProcAddress("eglDestroyImageKHR")); + return fn; +} + +void destroyEglImage(EGLDisplay dpy, EGLImageKHR image) { + if (image == EGL_NO_IMAGE_KHR) return; + PfnDestroyImageKHR fn = eglDestroyImageProc(); + if (!fn) { + static bool warned = false; + if (!warned) { + warned = true; + qCWarning( + vaapiLog, + "VideoPlayer: eglDestroyImageKHR unavailable, EGL images leak"); + } + return; + } + fn(dpy, image); +} + +EGLImageKHR createDmaImage( + EGLDisplay dpy, + int fd, + uint32_t offset, + uint32_t pitch, + uint32_t fourcc, + uint64_t modifier) { + PfnCreateImageKHR fn = eglCreateImageProc(); + if (!fn) return EGL_NO_IMAGE_KHR; + + EGLint attrs[16]; + int n = 0; + attrs[n++] = EGL_LINUX_DRM_FOURCC_EXT; + attrs[n++] = EGLint(fourcc); + attrs[n++] = EGL_DMA_BUF_PLANE0_FD_EXT; + attrs[n++] = fd; + attrs[n++] = EGL_DMA_BUF_PLANE0_OFFSET_EXT; + attrs[n++] = EGLint(offset); + attrs[n++] = EGL_DMA_BUF_PLANE0_PITCH_EXT; + attrs[n++] = EGLint(pitch); + if (modifier != DRM_FORMAT_MOD_INVALID) { + attrs[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT; + attrs[n++] = EGLint(modifier & 0xffffffffULL); + attrs[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT; + attrs[n++] = EGLint(modifier >> 32); + } + attrs[n] = EGL_NONE; + + EGLImageKHR image = + fn(dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs); + if (image == EGL_NO_IMAGE_KHR) return EGL_NO_IMAGE_KHR; + return image; +} +#endif + +} // namespace + +VaapiPlayer::VaapiPlayer(EGLDisplay display) : m_eglDisplay(display) {} + +VaapiPlayer::~VaapiPlayer() { + stop(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + av_frame_free(&m_pending); + } + if (m_displayed) { + av_frame_unref(m_displayed); + av_frame_free(&m_displayed); + } + if (m_hwDeviceCtx) { + av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + } + +#ifdef ZHAVE_VAAPI + if (m_imageY != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } +#endif +} + +bool VaapiPlayer::available() { + AVBufferRef* ref = nullptr; + if (av_hwdevice_ctx_create( + &ref, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0) < 0) { + if (ref) av_buffer_unref(&ref); + return false; + } + av_buffer_unref(&ref); + return true; +} + +bool VaapiPlayer::ensureDevice() { + if (m_hwDeviceCtx) return true; + if (av_hwdevice_ctx_create( + &m_hwDeviceCtx, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0) < 0) { + if (m_hwDeviceCtx) av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + qCWarning(vaapiLog, "VideoPlayer: could not create vaapi hwdevice"); + return false; + } + return true; +} + +bool VaapiPlayer::openDecoder(const QString& path) { + if (avformat_open_input( + &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { + qCWarning( + vaapiLog, + "VideoPlayer: could not open %s", + path.toUtf8().constData()); + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not read stream info"); + return false; + } + + m_streamIndex = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + m_streamIndex = int(i); + break; + } + if (m_streamIndex < 0) { + qCWarning(vaapiLog, "VideoPlayer: no video stream"); + return false; + } + + AVCodecParameters* par = m_fmt->streams[m_streamIndex]->codecpar; + const AVCodec* codec = avcodec_find_decoder(par->codec_id); + const AVCodec* hwcodec = nullptr; + void* it = nullptr; + while (const AVCodec* c = av_codec_iterate(&it)) { + if (c->id != par->codec_id || !av_codec_is_decoder(c)) continue; + for (int i = 0; + const AVCodecHWConfig* cfg = avcodec_get_hw_config(c, i); + ++i) + if (cfg->device_type == AV_HWDEVICE_TYPE_VAAPI && + (cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) { + hwcodec = c; + break; + } + if (hwcodec) break; + } + if (hwcodec) codec = hwcodec; + if (!codec) { + qCWarning( + vaapiLog, + "VideoPlayer: no decoder for codec id %d", + int(par->codec_id)); + return false; + } + + m_codec = avcodec_alloc_context3(codec); + if (!m_codec) return false; + if (avcodec_parameters_to_context(m_codec, par) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not copy codec params"); + return false; + } + + if (!ensureDevice()) { + return false; + } + m_codec->hw_device_ctx = av_buffer_ref(m_hwDeviceCtx); + m_codec->get_format = chooseFormat; + + if (avcodec_open2(m_codec, codec, nullptr) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not open codec"); + return false; + } + + return true; +} + +void VaapiPlayer::closeDecoder() { + if (m_codec) { + avcodec_free_context(&m_codec); + m_codec = nullptr; + } + if (m_fmt) { + avformat_close_input(&m_fmt); + m_fmt = nullptr; + } + m_streamIndex = -1; +} + +void VaapiPlayer::decodeLoop(const QString& path, uint32_t generation) { + if (!m_threadRunning.load(std::memory_order_acquire) || + m_generation.load(std::memory_order_acquire) != generation) + return; + + if (!openDecoder(path)) { + m_threadRunning.store(false); + return; + } + + AVRational fr = m_fmt->streams[m_streamIndex]->avg_frame_rate; + if (fr.num <= 0 || fr.den <= 0) fr = m_codec->framerate; + if (fr.num <= 0 || fr.den <= 0) fr = AVRational{30, 1}; + const int64_t intervalUs = + av_rescale_q(1, AVRational{fr.den, fr.num}, AVRational{1, 1000000}); + + AVFrame* frame = av_frame_alloc(); + if (!frame) { + m_threadRunning.store(false); + return; + } + + AVPacket pkt{}; + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation) { + int ret = av_read_frame(m_fmt, &pkt); + if (ret == AVERROR_EOF) { + if (avformat_seek_file( + m_fmt, m_streamIndex, INT64_MIN, 0, INT64_MAX, 0) < 0) { + qCWarning(vaapiLog, "VideoPlayer: loop seek failed; stopping"); + break; + } + avcodec_flush_buffers(m_codec); + continue; + } + if (ret < 0) { + qCWarning(vaapiLog, "VideoPlayer: read error; stopping"); + break; + } + if (pkt.stream_index != m_streamIndex) { + av_packet_unref(&pkt); + continue; + } + + if (avcodec_send_packet(m_codec, &pkt) < 0) { + av_packet_unref(&pkt); + continue; + } + av_packet_unref(&pkt); + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation && + avcodec_receive_frame(m_codec, frame) == 0) { + bool wasEmpty = false; + { + std::lock_guard lock(m_mtx); + if (!m_pending) m_pending = av_frame_alloc(); + av_frame_unref(m_pending); + if (av_frame_ref(m_pending, frame) >= 0) { + wasEmpty = !m_haveFrame; + m_haveFrame = true; + } + } + if (wasEmpty && m_readyCb) m_readyCb(); + if (m_frameCb) m_frameCb(); + av_frame_unref(frame); + } + + std::this_thread::sleep_for(std::chrono::microseconds(intervalUs)); + } + + av_frame_free(&frame); + closeDecoder(); + m_threadRunning.store(false); +} + +bool VaapiPlayer::start(const QString& path) { + if (!ensureDevice()) return false; + + const uint32_t generation = + m_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + m_threadRunning.store(true); + + std::thread previous = std::move(m_thread); + m_thread = std::thread( + [this, path, generation, previous = std::move(previous)]() mutable { + if (previous.joinable()) previous.join(); + closeDecoder(); + decodeLoop(path, generation); + }); + + return true; +} + +void VaapiPlayer::stop() { + m_generation.fetch_add(1, std::memory_order_acq_rel); + m_threadRunning.store(false); + if (m_thread.joinable()) m_thread.join(); + + closeDecoder(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + m_pending = nullptr; + } + if (m_displayed) { + av_frame_unref(m_displayed); + m_displayed = nullptr; + } + m_haveFrame = false; + m_size = QSize(); +} + +void VaapiPlayer::deleteTexture() { +#ifdef ZHAVE_VAAPI + if (m_imageY != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } +#endif + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + m_texSize = QSize(); +} + +bool VaapiPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { + const QSize sz = QSize(int(w), int(h)); + if (m_texSize == sz && m_texComp == compBytes && m_texY != 0) return true; + + if (m_texY) glDeleteTextures(1, &m_texY); + if (m_texUV) glDeleteTextures(1, &m_texUV); + m_texY = m_texUV = 0; + + const bool wide = compBytes == 2; + auto makeTex = + [&](GLuint& tex, GLint internal, GLenum fmt, unsigned tw, unsigned th) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal, + GLint(tw), + GLint(th), + 0, + fmt, + wide ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE, + nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + makeTex(m_texY, wide ? GL_R16 : GL_R8, GL_RED, w, h); + makeTex(m_texUV, wide ? GL_RG16 : GL_RG8, GL_RG, w / 2, h / 2); + + m_texSize = sz; + m_texComp = compBytes; + return true; +} + +bool VaapiPlayer::importCurrentFrame() { + AVFrame* frame = nullptr; + { + std::lock_guard lock(m_mtx); + if (!m_pending || m_pending->format == AV_PIX_FMT_NONE) return false; + frame = m_pending; + m_pending = nullptr; + } + + if (frame->format != AV_PIX_FMT_VAAPI || !frame->hw_frames_ctx) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, + "VideoPlayer: frame not on GPU (fmt=%s hw_frames=%d)", + av_get_pix_fmt_name(AVPixelFormat(frame->format)), + frame->hw_frames_ctx != nullptr); + av_frame_unref(frame); + return false; + } + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVPixelFormat sw = hf->sw_format; + const bool tenBit = sw == AV_PIX_FMT_P010LE || sw == AV_PIX_FMT_P010BE || + sw == AV_PIX_FMT_P012LE || sw == AV_PIX_FMT_P016LE; + const int compBytes = tenBit ? 2 : 1; + const size_t compSize = size_t(compBytes); + + const unsigned w = unsigned(frame->width); + const unsigned h = unsigned(frame->height); + if (w == 0 || h == 0) { + av_frame_unref(frame); + return false; + } + + switch (frame->colorspace) { + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + m_matrix = 0; + break; + case AVCOL_SPC_BT709: + m_matrix = 1; + break; + default: + m_matrix = h >= 720 ? 1 : 0; + break; + } + m_range = frame->color_range == AVCOL_RANGE_JPEG ? 1 : 0; + m_size = QSize(int(w), int(h)); + +#ifdef ZHAVE_VAAPI + if (importEglImages(frame)) { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + return true; + } +#endif + + AVFrame* tmp = av_frame_alloc(); + if (!tmp) { + av_frame_unref(frame); + return false; + } + if (av_hwframe_transfer_data(tmp, frame, 0) < 0) { + av_frame_free(&tmp); + av_frame_unref(frame); + return false; + } + + size_t pitchY = size_t(tmp->linesize[0]); + size_t pitchUV = tmp->linesize[1] > 0 ? size_t(tmp->linesize[1]) : pitchY; + if (pitchY == 0) pitchY = size_t(w) * compSize; + + if (!ensureTextures(w, h, compBytes)) { + av_frame_free(&tmp); + av_frame_unref(frame); + return false; + } + + const GLenum type = compBytes == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + + glBindTexture(GL_TEXTURE_2D, m_texY); + glPixelStorei(GL_UNPACK_ROW_LENGTH, GLint(pitchY / compSize)); + glTexSubImage2D( + GL_TEXTURE_2D, 0, 0, 0, GLint(w), GLint(h), GL_RED, type, tmp->data[0]); + + glBindTexture(GL_TEXTURE_2D, m_texUV); + glPixelStorei(GL_UNPACK_ROW_LENGTH, GLint(pitchUV / (compSize * 2))); + glTexSubImage2D( + GL_TEXTURE_2D, + 0, + 0, + 0, + GLint(w / 2), + GLint(h / 2), + GL_RG, + type, + tmp->data[1]); + + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + + av_frame_free(&tmp); + + { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + } + + return true; +} + +#ifdef ZHAVE_VAAPI +bool VaapiPlayer::importEglImages(AVFrame* frame) { + if (m_eglDisplay == EGL_NO_DISPLAY) return false; + GLEGLImageTargetProc target = glEGLImageTarget(); + if (!target) return false; + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVHWDeviceContext* dev = hf->device_ctx; + const AVVAAPIDeviceContext* va = + reinterpret_cast(dev->hwctx); + if (!va->display) return false; + + const VASurfaceID surface = VASurfaceID(uintptr_t(frame->data[0])); + + VADRMPRIMESurfaceDescriptor desc{}; + VAStatus st = vaExportSurfaceHandle( + va->display, + surface, + VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2, + VA_EXPORT_SURFACE_READ_ONLY | VA_EXPORT_SURFACE_SEPARATE_LAYERS, + &desc); + if (st != VA_STATUS_SUCCESS) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, + "VideoPlayer: vaExportSurfaceHandle failed (%d); CPU fallback", + int(st)); + return false; + } + + if (desc.num_layers != 2 || desc.num_objects < 2) { + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + return false; + } + + const auto bindLayer = [&](uint32_t layer, GLuint tex) -> EGLImageKHR { + const auto& L = desc.layers[layer]; + if (L.num_planes < 1) return EGL_NO_IMAGE_KHR; + const auto& obj = desc.objects[L.object_index[0]]; + if (obj.fd < 0) return EGL_NO_IMAGE_KHR; + EGLImageKHR image = createDmaImage( + m_eglDisplay, + obj.fd, + L.offset[0], + L.pitch[0], + L.drm_format, + obj.drm_format_modifier); + if (image == EGL_NO_IMAGE_KHR) return EGL_NO_IMAGE_KHR; + glBindTexture(GL_TEXTURE_2D, tex); + target(GL_TEXTURE_2D, image); + return image; + }; + + const QSize sz = QSize(int(frame->width), int(frame->height)); + if (m_texSize != sz || m_texY == 0) { + if (m_imageY != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } + if (m_texY == 0) glGenTextures(1, &m_texY); + if (m_texUV == 0) glGenTextures(1, &m_texUV); + glBindTexture(GL_TEXTURE_2D, m_texY); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, m_texUV); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + m_texSize = sz; + } + + EGLImageKHR imageY = bindLayer(0, m_texY); + EGLImageKHR imageUV = bindLayer(1, m_texUV); + + if (imageY == EGL_NO_IMAGE_KHR || imageUV == EGL_NO_IMAGE_KHR) { + destroyEglImage(m_eglDisplay, imageY); + destroyEglImage(m_eglDisplay, imageUV); + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, "VideoPlayer: EGLImage import failed; CPU fallback"); + return false; + } + + if (m_imageY != EGL_NO_IMAGE_KHR) destroyEglImage(m_eglDisplay, m_imageY); + if (m_imageUV != EGL_NO_IMAGE_KHR) destroyEglImage(m_eglDisplay, m_imageUV); + + m_imageY = imageY; + m_imageUV = imageUV; + + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + + return true; +} +#endif + +} // namespace ZShell::wallpaper + +#endif diff --git a/Plugins/ZShell/Wallpaper/vaapiplayer.hpp b/Plugins/ZShell/Wallpaper/vaapiplayer.hpp new file mode 100644 index 0000000..aacf26a --- /dev/null +++ b/Plugins/ZShell/Wallpaper/vaapiplayer.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "videodecoder.hpp" + +#include + +#include +#include +#include + +#include +#include + +#include +#ifdef ZHAVE_VAAPI +#include +#endif + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} + +namespace ZShell::wallpaper { + +class VaapiPlayer : public VideoDecoder { + public: + explicit VaapiPlayer(EGLDisplay display); + ~VaapiPlayer() override; + + [[nodiscard]] static bool available(); + + bool start(const QString& path) override; + void stop() override; + bool importCurrentFrame() override; + void deleteTexture() override; + + [[nodiscard]] const char* backendName() const override { return "vaapi"; } + + private: + void decodeLoop(const QString& path, uint32_t generation); + bool openDecoder(const QString& path); + void closeDecoder(); + + bool ensureDevice(); + bool ensureTextures(unsigned w, unsigned h, int compBytes); + +#ifdef ZHAVE_VAAPI + bool importEglImages(AVFrame* frame); +#endif + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + + std::thread m_thread; + std::atomic m_threadRunning{false}; + std::atomic m_generation{0}; + + mutable std::mutex m_mtx; + AVFrame* m_pending = nullptr; + bool m_haveFrame = false; + + AVFrame* m_displayed = nullptr; + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_codec = nullptr; + AVBufferRef* m_hwDeviceCtx = nullptr; + int m_streamIndex = -1; + + QSize m_texSize; + int m_texComp = 1; + +#ifdef ZHAVE_VAAPI + EGLImageKHR m_imageY = EGL_NO_IMAGE_KHR; + EGLImageKHR m_imageUV = EGL_NO_IMAGE_KHR; +#endif +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videodecoder.cpp b/Plugins/ZShell/Wallpaper/videodecoder.cpp new file mode 100644 index 0000000..52e5e67 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videodecoder.cpp @@ -0,0 +1,117 @@ +#include "videodecoder.hpp" + +#include + +#ifdef ZHAVE_VIDEO +#include "nvidiaplayer.hpp" +#endif + +#ifdef ZHAVE_FFMPEG +#include "vaapiplayer.hpp" +#endif + +namespace ZShell::wallpaper { + +VideoDecoder::~VideoDecoder() = default; + +void VideoDecoder::setReadyCallback(std::function callback) { + m_readyCb = std::move(callback); +} + +void VideoDecoder::setFrameCallback(std::function callback) { + m_frameCb = std::move(callback); +} + +uint32_t VideoDecoder::textureId() const { + return m_texY; +} + +uint32_t VideoDecoder::chromaTextureId() const { + return m_texUV; +} + +int VideoDecoder::colorMatrix() const { + return m_matrix; +} + +int VideoDecoder::colorRange() const { + return m_range; +} + +QSize VideoDecoder::frameSize() const { + return m_size; +} + +namespace { + +VideoBackend backendFromEnvironment() { + if (qEnvironmentVariableIsSet("ZBAR_WALLPAPER_VAAPI")) + return VideoBackend::Vaapi; + if (qEnvironmentVariableIsSet("ZBAR_WALLPAPER_NVIDIA")) + return VideoBackend::Nvidia; + return VideoBackend::Auto; +} + +VideoBackend selectBackend(VideoBackend hint) { + if (hint == VideoBackend::None) return VideoBackend::None; + if (hint == VideoBackend::Auto) hint = backendFromEnvironment(); + + if (hint == VideoBackend::Nvidia) { +#ifdef ZHAVE_VIDEO + if (NvidiaPlayer::available()) return VideoBackend::Nvidia; +#endif + return VideoBackend::None; + } + + if (hint == VideoBackend::Vaapi) { +#ifdef ZHAVE_FFMPEG + if (VaapiPlayer::available()) return VideoBackend::Vaapi; +#endif + return VideoBackend::None; + } + +#ifdef ZHAVE_VIDEO + if (NvidiaPlayer::available()) return VideoBackend::Nvidia; +#endif + +#ifdef ZHAVE_FFMPEG + if (VaapiPlayer::available()) return VideoBackend::Vaapi; +#endif + + return VideoBackend::None; +} + +} // namespace + +std::unique_ptr createVideoDecoder( + EGLDisplay display, VideoBackend hint) { + switch (selectBackend(hint)) { +#ifdef ZHAVE_VIDEO + case VideoBackend::Nvidia: + return std::make_unique(display); +#endif +#ifdef ZHAVE_FFMPEG + case VideoBackend::Vaapi: + return std::make_unique(display); +#endif + case VideoBackend::Auto: + case VideoBackend::None: + break; + } + return nullptr; +} + +const char* resolveBackendName(VideoBackend hint) { + switch (selectBackend(hint)) { + case VideoBackend::Nvidia: + return "nvidia"; + case VideoBackend::Vaapi: + return "vaapi"; + case VideoBackend::Auto: + case VideoBackend::None: + break; + } + return "mpv"; +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videodecoder.hpp b/Plugins/ZShell/Wallpaper/videodecoder.hpp new file mode 100644 index 0000000..2275aaf --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videodecoder.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +namespace ZShell::wallpaper { + +class VideoDecoder { + public: + virtual ~VideoDecoder(); + + void setReadyCallback(std::function callback); + void setFrameCallback(std::function callback); + + virtual bool start(const QString& path) = 0; + virtual void stop() = 0; + + virtual bool importCurrentFrame() = 0; + virtual void deleteTexture() = 0; + + [[nodiscard]] uint32_t textureId() const; + [[nodiscard]] uint32_t chromaTextureId() const; + [[nodiscard]] int colorMatrix() const; + [[nodiscard]] int colorRange() const; + [[nodiscard]] QSize frameSize() const; + + [[nodiscard]] virtual const char* backendName() const = 0; + + protected: + std::function m_readyCb; + std::function m_frameCb; + + uint32_t m_texY = 0; + uint32_t m_texUV = 0; + int m_matrix = 1; + int m_range = 0; + QSize m_size; +}; + +enum class VideoBackend { + Auto, + Nvidia, + Vaapi, + None, +}; + +std::unique_ptr createVideoDecoder( + EGLDisplay display, VideoBackend hint = VideoBackend::Auto); + +const char* resolveBackendName(VideoBackend hint = VideoBackend::Auto); + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videotranscoder.cpp b/Plugins/ZShell/Wallpaper/videotranscoder.cpp new file mode 100644 index 0000000..5e998ab --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videotranscoder.cpp @@ -0,0 +1,165 @@ +#include "videotranscoder.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(transcodeLog, "ZShell.Internal.wallpaper") + +namespace ZShell::wallpaper { + +VideoTranscoder::VideoTranscoder(QObject* parent) : QObject(parent) {} + +bool VideoTranscoder::isHardwareCodec(const QString& codec) { + static const QStringList hardware{ + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", + }; + + return hardware.contains(codec); +} + +QString VideoTranscoder::codecName(const QString& path) const { + QProcess probe; + probe.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + + if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { + qWarning(transcodeLog) << "WallpaperService: ffprobe failed for" << path + << "(exit" << probe.exitCode() << ")"; + return {}; + } + + return QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); +} + +QString VideoTranscoder::cacheDir() const { + return QDir::homePath() + "/.cache/zshell/wallpaper-videos"; +} + +QString VideoTranscoder::transcodeTarget(const QString& path) const { + const QFileInfo info(path); + + const QByteArray key = + path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch()); + + const QString hash = QString::fromUtf8( + QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex()); + + return cacheDir() + '/' + hash + ".av1.mkv"; +} + +bool VideoTranscoder::needsTranscode(const QString& path) const { + if (path.startsWith(cacheDir())) return false; + + const QString codec = codecName(path); + if (codec.isEmpty()) return false; + + return !isHardwareCodec(codec); +} + +void VideoTranscoder::start(const QString& src, const QString& dst) { + QDir().mkpath(cacheDir()); + + if (m_process) { + m_process->kill(); + m_process->deleteLater(); + m_process = nullptr; + } + + m_process = new QProcess(this); + auto* proc = m_process; + + connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { + qWarning(transcodeLog).noquote() + << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); + }); + + connect( + proc, + &QProcess::finished, + this, + [this, proc, dst](int code, QProcess::ExitStatus status) { + const bool ok = code == 0 && status == QProcess::NormalExit && + QFile::exists(dst); + + qWarning(transcodeLog) + << "WallpaperService: transcode finished, exit" << code + << "status" << int(status) << "exists" << QFile::exists(dst) + << "->" << dst; + + if (proc == m_process) m_process = nullptr; + proc->deleteLater(); + + emit transcoded(dst, ok); + }); + + qWarning(transcodeLog) << "WallpaperService: transcoding" << src << "->" + << dst; + + proc->setProgram("nice"); + proc->setArguments({ + "-n", + "19", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-threads", + "2", + "-i", + src, + "-map", + "0:v:0", + "-vf", + "scale=in_range=pc:out_range=tv:out_color_matrix=bt709", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-c:v", + "libsvtav1", + "-preset", + "6", + "-crf", + "20", + "-pix_fmt", + "yuv420p10le", + "-an", + dst, + }); + + proc->start(); +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videotranscoder.hpp b/Plugins/ZShell/Wallpaper/videotranscoder.hpp new file mode 100644 index 0000000..9879d6f --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videotranscoder.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +class QProcess; + +namespace ZShell::wallpaper { + +class VideoTranscoder : public QObject { + Q_OBJECT + + public: + explicit VideoTranscoder(QObject* parent = nullptr); + + [[nodiscard]] QString cacheDir() const; + [[nodiscard]] QString transcodeTarget(const QString& path) const; + [[nodiscard]] bool needsTranscode(const QString& path) const; + + void start(const QString& src, const QString& dst); + + static bool isHardwareCodec(const QString& codec); + + signals: + void transcoded(const QString& dst, bool ok); + + private: + [[nodiscard]] QString codecName(const QString& path) const; + + QProcess* m_process = nullptr; +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpapercache.cpp b/Plugins/ZShell/Wallpaper/wallpapercache.cpp new file mode 100644 index 0000000..a0aeb2e --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpapercache.cpp @@ -0,0 +1,170 @@ +#include "wallpapercache.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::wallpaper { + +namespace { + +QString connectionName() { + return QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); +} + +QSqlDatabase openDatabase(const QString& databasePath) { + const QString connection = connectionName(); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) return {}; + return database; +} + +} // namespace + +void WallpaperCache::setDbPath(const QString& path) { + std::lock_guard lock(m_mtx); + m_dbPath = path; +} + +QString WallpaperCache::dbPath() const { + std::lock_guard lock(m_mtx); + return m_dbPath; +} + +QByteArray WallpaperCache::cachedBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT cache, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QImage WallpaperCache::decodeImage( + const QString& path, const QSize& decodeSize) const { + if (path.isEmpty()) return {}; + + QImageReader probe(path); + const QSize source = probe.size(); + QSize required = source.isEmpty() ? decodeSize : source; + if (!source.isEmpty() && !decodeSize.isEmpty()) { + const QSize cover = + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && + cover.height() <= source.height()) + required = cover; + } + + const QByteArray blob = cachedBlob(path); + if (!blob.isEmpty()) { + QImage image = QImage::fromData(blob, "JPEG"); + if (!image.isNull() && image.width() >= required.width() && + image.height() >= required.height()) { + return image.convertToFormat(QImage::Format_RGBA8888); + } + } + + QImageReader reader(path); + reader.setAutoTransform(true); + + if (!decodeSize.isEmpty() && !source.isEmpty()) + reader.setScaledSize( + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding)); + + QImage image = reader.read(); + if (image.isNull()) return {}; + + return image.convertToFormat(QImage::Format_RGBA8888); +} + +QByteArray WallpaperCache::posterBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT poster, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QVariantMap WallpaperCache::cropsForPath(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT crop FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray json = query.value(0).toByteArray(); + if (json.isEmpty()) return {}; + + const auto doc = QJsonDocument::fromJson(json); + if (!doc.isObject()) return {}; + + return doc.object().toVariantMap(); +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpapercache.hpp b/Plugins/ZShell/Wallpaper/wallpapercache.hpp new file mode 100644 index 0000000..a0f6ad3 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpapercache.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell::wallpaper { + +class WallpaperCache { + public: + void setDbPath(const QString& path); + [[nodiscard]] QString dbPath() const; + + [[nodiscard]] QImage decodeImage( + const QString& path, const QSize& decodeSize) const; + + [[nodiscard]] QByteArray posterBlob(const QString& path) const; + + [[nodiscard]] QVariantMap cropsForPath(const QString& path) const; + + private: + [[nodiscard]] QByteArray cachedBlob(const QString& path) const; + + mutable std::mutex m_mtx; + QString m_dbPath; +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Wallpaper/wallpaperimage.cpp similarity index 99% rename from Plugins/ZShell/Internal/wallpaperimage.cpp rename to Plugins/ZShell/Wallpaper/wallpaperimage.cpp index f6de9de..897dcb2 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Wallpaper/wallpaperimage.cpp @@ -8,7 +8,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { QThreadPool* WallpaperImage::loadPool() { static QThreadPool* pool = [] { @@ -352,4 +352,4 @@ QSGNode* WallpaperImage::updatePaintNode( return m_rootNode; } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperimage.hpp b/Plugins/ZShell/Wallpaper/wallpaperimage.hpp similarity index 98% rename from Plugins/ZShell/Internal/wallpaperimage.hpp rename to Plugins/ZShell/Wallpaper/wallpaperimage.hpp index 6ecf562..8226759 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.hpp +++ b/Plugins/ZShell/Wallpaper/wallpaperimage.hpp @@ -14,7 +14,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperImage : public QQuickItem { Q_OBJECT @@ -144,4 +144,4 @@ class WallpaperImage : public QQuickItem { QSGImageNode* m_oldImageNode = nullptr; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Wallpaper/wallpaperobject.cpp similarity index 60% rename from Plugins/ZShell/Internal/wallpaperobject.cpp rename to Plugins/ZShell/Wallpaper/wallpaperobject.cpp index b11057e..143a637 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.cpp +++ b/Plugins/ZShell/Wallpaper/wallpaperobject.cpp @@ -1,6 +1,6 @@ #include "wallpaperobject.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {} @@ -67,8 +67,46 @@ void WallpaperObject::setZoom(qreal zoom) { push(); } +void WallpaperObject::syncCrop(const QVariantMap& crop) { + const qreal x = crop.value("x").toDouble(); + const qreal y = crop.value("y").toDouble(); + const qreal width = crop.contains("width") ? crop.value("width").toDouble() + : 1.0; + const qreal height = crop.contains("height") + ? crop.value("height").toDouble() + : 1.0; + const qreal z = + crop.contains("zoom") ? crop.value("zoom").toDouble() : 1.0; + + const bool prev = m_pushEnabled; + m_pushEnabled = false; + + if (!qFuzzyCompare(m_cropX, x)) { + m_cropX = x; + emit cropXChanged(); + } + if (!qFuzzyCompare(m_cropY, y)) { + m_cropY = y; + emit cropYChanged(); + } + if (!qFuzzyCompare(m_cropWidth, width)) { + m_cropWidth = width; + emit cropWidthChanged(); + } + if (!qFuzzyCompare(m_cropHeight, height)) { + m_cropHeight = height; + emit cropHeightChanged(); + } + if (!qFuzzyCompare(m_zoom, z)) { + m_zoom = z; + emit zoomChanged(); + } + + m_pushEnabled = prev; +} + void WallpaperObject::push() { - if (!m_service || m_screen.isEmpty()) return; + if (!m_pushEnabled || !m_service || m_screen.isEmpty()) return; QVariantMap crop; crop.insert("x", m_cropX); @@ -80,4 +118,4 @@ void WallpaperObject::push() { m_service->setCrop(m_screen, crop); } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Wallpaper/wallpaperobject.hpp similarity index 92% rename from Plugins/ZShell/Internal/wallpaperobject.hpp rename to Plugins/ZShell/Wallpaper/wallpaperobject.hpp index eb3d8a4..e2052be 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.hpp +++ b/Plugins/ZShell/Wallpaper/wallpaperobject.hpp @@ -9,7 +9,7 @@ #include "wallpaperservice.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperObject : public QObject { Q_OBJECT @@ -55,6 +55,8 @@ class WallpaperObject : public QObject { qreal zoom() const { return m_zoom; } void setZoom(qreal zoom); + Q_INVOKABLE void syncCrop(const QVariantMap& crop); + signals: void serviceChanged(); void screenChanged(); @@ -77,6 +79,8 @@ class WallpaperObject : public QObject { qreal m_cropWidth = 1.0; qreal m_cropHeight = 1.0; qreal m_zoom = 1.0; + + bool m_pushEnabled = true; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpaperservice.cpp b/Plugins/ZShell/Wallpaper/wallpaperservice.cpp new file mode 100644 index 0000000..7ff181f --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpaperservice.cpp @@ -0,0 +1,2515 @@ +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include "wallpaperservice.hpp" + +#include +#include +#include + +#ifndef GL_TEXTURE_EXTERNAL_OES +#define GL_TEXTURE_EXTERNAL_OES 0x8D66 +#endif + +#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 "config/background.hpp" +#include "config/rootnodes.hpp" +#include "config/services.hpp" + +#include + +namespace ZShell::wallpaper { + +Q_LOGGING_CATEGORY(wpLog, "ZShell.Internal.wallpaper") + +namespace { + +const bool kMpvMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_MPV"); + +const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES") || + kMpvMode; + +VideoBackend backendFromConfig() { + const auto type = + ZShell::config::ConfigSingleton::instance()->services()->gpuType(); + switch (type) { + case ZShell::config::GpuType::Nvidia: + return VideoBackend::Nvidia; + case ZShell::config::GpuType::Generic: + return VideoBackend::Vaapi; + case ZShell::config::GpuType::None: + return VideoBackend::None; + case ZShell::config::GpuType::Auto: + default: + return VideoBackend::Auto; + } +} + +void logSelectedBackend() { + qInfo(wpLog) << "WallpaperService: video backend =" + << resolveBackendName(backendFromConfig()); +} + +const char* shaderHeader(bool fragment) { + if (kGlesMode) + return fragment ? "#version 300 es\nprecision highp float;\n" + : "#version 300 es\n"; + return "#version 330\n"; +} + +QByteArray shaderSource(const QString& file, bool fragment) { + QFile source(QStringLiteral(":/shaders/") + file); + if (!source.open(QIODevice::ReadOnly)) { + qWarning(wpLog) << "WallpaperService: could not read the shader" + << file; + return {}; + } + + return QByteArray(shaderHeader(fragment)) + source.readAll(); +} + +const char* transitionFile(ZShell::config::WallpaperTransition::Enum value) { + switch (value) { + case ZShell::config::WallpaperTransition::Bowtie: + return "wallpaper_trans_bowtie.frag"; + case ZShell::config::WallpaperTransition::Fade: + return "wallpaper_trans_fade.frag"; + case ZShell::config::WallpaperTransition::LinearBlur: + return "wallpaper_trans_linear_blur.frag"; + case ZShell::config::WallpaperTransition::WindowSlices: + return "wallpaper_trans_window_slices.frag"; + case ZShell::config::WallpaperTransition::WipeRight: + return "wallpaper_trans_wipe_right.frag"; + case ZShell::config::WallpaperTransition::WipeDown: + return "wallpaper_trans_wipe_down.frag"; + case ZShell::config::WallpaperTransition::TilesWave: + return "wallpaper_trans_tiles_wave.frag"; + case ZShell::config::WallpaperTransition::SplitSlideInHorizontal: + return "wallpaper_trans_split_slide_in_horizontal.frag"; + case ZShell::config::WallpaperTransition::Hexagonalize: + return "wallpaper_trans_hexagonalize.frag"; + case ZShell::config::WallpaperTransition::DoomMelt: + return "wallpaper_trans_doom_melt.frag"; + case ZShell::config::WallpaperTransition::Random: + break; + } + + return "wallpaper_trans_fade.frag"; +} + +const QStringList& transitionFilesList() { + static const QStringList files = { + "wallpaper_trans_bowtie.frag", + "wallpaper_trans_fade.frag", + "wallpaper_trans_linear_blur.frag", + "wallpaper_trans_window_slices.frag", + "wallpaper_trans_wipe_right.frag", + "wallpaper_trans_wipe_down.frag", + "wallpaper_trans_tiles_wave.frag", + "wallpaper_trans_split_slide_in_horizontal.frag", + "wallpaper_trans_hexagonalize.frag", + "wallpaper_trans_doom_melt.frag", + }; + return files; +} + +constexpr int kWaitTimeoutMs = 20; + +constexpr int kFadeDurationMs = 400; + +const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +void logShader(uint32_t object, bool program) { + int ok = 0; + if (program) + glGetProgramiv(object, GL_LINK_STATUS, &ok); + else + glGetShaderiv(object, GL_COMPILE_STATUS, &ok); + + if (ok) return; + + char info[512] = {}; + if (program) + glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info); + else + glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); + + qWarning(wpLog) << "WallpaperService:" + << (program ? "linking" : "compiling") << "failed:" << info; +} + +QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { + QVariantMap crop = crops.value(screen).toMap(); + + if (crop.isEmpty()) { + crop.insert("x", 0.0); + crop.insert("y", 0.0); + crop.insert("width", 1.0); + crop.insert("height", 1.0); + crop.insert("zoom", 1.0); + } + + return crop; +} + +} // namespace + +WallpaperService::WallpaperService(QObject* parent) : QObject(parent) { + auto* background = + ZShell::config::ConfigSingleton::instance()->background(); + + { + std::lock_guard lock(m_mutex); + m_randomTransition = background->wallTransition() == + ZShell::config::WallpaperTransition::Random; + m_transitionFile = transitionFileFor(background->wallTransition()); + } + + QObject::connect( + background, + &ZShell::config::Background::wallTransitionChanged, + this, + [this, background] { + { + std::lock_guard lock(m_mutex); + m_randomTransition = + background->wallTransition() == + ZShell::config::WallpaperTransition::Random; + m_transitionFile = + transitionFileFor(background->wallTransition()); + m_transitionDirty = true; + } + + wake(); + }); + + m_transcoder = new VideoTranscoder(this); + connect( + m_transcoder, + &VideoTranscoder::transcoded, + this, + [this](const QString& dst, bool ok) { + if (ok) applyPath(dst); + }); + + auto* gpuConfig = ZShell::config::ConfigSingleton::instance()->services(); + logSelectedBackend(); + QObject::connect( + gpuConfig, &ZShell::config::Services::gpuTypeChanged, this, [] { + logSelectedBackend(); + }); +} + +WallpaperService::~WallpaperService() { + m_stop = true; + wake(); + + if (m_thread.joinable()) m_thread.join(); + + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + + for (auto* surface : m_surfaces) + delete surface; + m_surfaces.clear(); + + if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) { + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + if (m_texture) glDeleteTextures(1, &m_texture); + for (const auto& layer : m_transitions) + if (layer.tex) glDeleteTextures(1, &layer.tex); + m_transitions.clear(); + if (m_baseTex) glDeleteTextures(1, &m_baseTex); + if (m_baseFbo) glDeleteFramebuffers(1, &m_baseFbo); + if (m_accumA) glDeleteTextures(1, &m_accumA); + if (m_accumB) glDeleteTextures(1, &m_accumB); + if (m_accumFbo) glDeleteFramebuffers(1, &m_accumFbo); + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + if (m_blackTex) glDeleteTextures(1, &m_blackTex); + if (m_program) glDeleteProgram(m_program); + if (m_videoProgram) glDeleteProgram(m_videoProgram); + for (const auto& p : m_transPrograms) + if (p.id) glDeleteProgram(p.id); + m_transPrograms.clear(); + m_neutralProgram = TransProgram(); + if (m_captureTex) glDeleteTextures(1, &m_captureTex); + if (m_captureFbo) glDeleteFramebuffers(1, &m_captureFbo); + if (m_posterTex) glDeleteTextures(1, &m_posterTex); +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + m_decoder->stop(); + m_decoder->deleteTexture(); + m_decoder.reset(); + } +#endif + } + + eglDestroyContext(m_eglDisplay, m_context); + } + + if (m_queue) wl_event_queue_destroy(m_queue); + + if (m_wake[0] >= 0) { + close(m_wake[0]); + close(m_wake[1]); + } + + if (m_layerShell) m_layerShell->destroy(); + if (m_viewporter) m_viewporter->destroy(); + if (m_scaleManager) m_scaleManager->destroy(); + if (m_registry) wl_registry_destroy(m_registry); +} + +QString WallpaperService::path() const { + std::lock_guard lock(m_mutex); + return m_path; +} + +QString WallpaperService::dbPath() const { + std::lock_guard lock(m_mutex); + return m_dbPath; +} + +void WallpaperService::setDbPath(const QString& path) { + { + std::lock_guard lock(m_mutex); + if (m_dbPath == path) return; + m_dbPath = path; + m_forceDecode = true; + } + + m_cache.setDbPath(path); + + emit dbPathChanged(); + wake(); +} + +void WallpaperService::setPath(const QString& path) { + if (path.isEmpty() || path == this->path()) return; + + if (isVideoPath(path) && m_transcoder->needsTranscode(path)) { + const QString dst = m_transcoder->transcodeTarget(path); + + if (QFile::exists(dst)) { + applyPath(dst); + return; + } + + applyPath(path); + m_transcoder->start(path, dst); + return; + } + + applyPath(path); +} + +void WallpaperService::applyPath(const QString& path) { + const bool video = isVideoPath(path); + + { + std::lock_guard lock(m_mutex); + const QString previousPath = m_path; + m_path = path; + + if (m_randomTransition) { + const QString next = randomTransitionFile(); + if (next != m_transitionFile) { + m_transitionFile = next; + m_transitionDirty = true; + } + } + +#ifdef ZHAVE_FFMPEG + const bool videoBackend = m_mpv != nullptr || m_decoder != nullptr; +#else + const bool videoBackend = m_mpv != nullptr; +#endif + const bool wasVideo = m_isVideo && videoBackend; + const bool wasImage = !m_image.isNull() && m_texture != 0; + const bool beginBurst = m_transitions.empty(); + + if (beginBurst) { + if (wasVideo) { + m_needCaptureBase = true; + m_baseIsVideo = true; + m_baseIsBlack = false; + m_baseImage = QImage(); + m_baseCrops = m_cache.cropsForPath(previousPath); + } else if (wasImage) { + m_needCaptureBase = true; + m_baseIsVideo = false; + m_baseIsBlack = false; + m_baseImage = m_image; + m_baseCrops = m_cache.cropsForPath(previousPath); + } else { + m_needCaptureBase = false; + m_baseIsVideo = false; + m_baseIsBlack = m_baseTex == 0; + m_baseImage = QImage(); + m_baseCrops = m_cache.cropsForPath(previousPath); + } + } + + if (!beginBurst && !m_transitions.empty() && + m_transitions.back().isVideo && !m_transitions.back().started && + !m_posterReady) + m_transitions.pop_back(); + + if (!beginBurst && !m_transitions.empty() && m_texture != 0 && + !m_transitions.back().isVideo) { + TransitionLayer& newest = m_transitions.back(); + newest.tex = m_texture; + newest.image = m_image; + newest.crops = m_cache.cropsForPath(previousPath); + newest.ready = true; + m_texture = 0; + m_image = QImage(); + } + + if (!beginBurst && video && !m_transitions.empty() && + m_transitions.back().isVideo) + m_collapseVideo = true; + + m_crops = m_cache.cropsForPath(path); + + m_isVideo = video; + + m_replacementReady = false; + m_forceDecode = true; + m_imageChanged = true; + m_videoReload = true; + + TransitionLayer layer; + layer.isVideo = video; + layer.transitionFile = m_transitionFile; + m_transitions.push_back(layer); + + if (static_cast(m_transitions.size()) > kMaxTransitions) + m_promoteOldest = true; + + while (static_cast(m_transitions.size()) > kMaxTransitions * 2) { + if (m_transitions.front().tex != 0) + m_orphanTextures.push_back(m_transitions.front().tex); + m_transitions.erase(m_transitions.begin()); + } + } + + emit pathChanged(); + + start(); + + queueAll(); +} + +void WallpaperService::addScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (m_screens.contains(screen)) return; + + m_screens.append(screen); + } + + start(); + + queueAll(); +} + +void WallpaperService::removeScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (!m_screens.removeOne(screen)) return; + } + + queueAll(); +} + +const QVariantMap& WallpaperService::crops() const { + std::lock_guard lock(m_mutex); + return m_crops; +} + +void WallpaperService::setCrops(const QVariantMap& crops) { + { + std::lock_guard lock(m_mutex); + + if (m_crops == crops) return; + + m_crops = crops; + } + + emit cropsChanged(); + queueAll(); +} + +void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) { + WallpaperSurface* surface = nullptr; + + { + std::lock_guard lock(m_mutex); + + if (m_crops.value(screen) == crop) return; + + m_crops.insert(screen, crop); + + for (auto* candidate : m_surfaces) { + if (candidate->screen() == screen) { + surface = candidate; + break; + } + } + } + + emit cropsChanged(); + + if (surface) queue(surface); +} + +void WallpaperService::redraw() { + queueAll(); +} + +bool WallpaperService::isVideoPath(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +void WallpaperService::startVideo(const QString& path) { + if (path.isEmpty()) return; + + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); + +#ifdef ZHAVE_FFMPEG + if (m_useDecoder && !kMpvMode) { + if (startDecoderVideo(path)) return; + + qWarning(wpLog) << "WallpaperService: the GPU video player failed, " + "falling back to mpv"; + m_useDecoder = false; + m_decoder.reset(); + } +#endif + + if (!m_mpv) { + std::setlocale(LC_NUMERIC, "C"); + + m_mpv = mpv_create(); + if (!m_mpv) { + qWarning(wpLog) << "WallpaperService: could not create mpv handle"; + return; + } + + mpv_set_option_string(m_mpv, "config", "no"); + mpv_set_option_string(m_mpv, "vo", "libmpv"); + mpv_set_option_string(m_mpv, "audio", "no"); + mpv_set_option_string(m_mpv, "loop", "inf"); + mpv_set_option_string(m_mpv, "hwdec", "auto-copy"); + mpv_set_option_string(m_mpv, "video-sync", "display"); + mpv_set_option_string(m_mpv, "framedrop", "decoder"); + mpv_set_option_string(m_mpv, "background-color", "#00000000"); + + if (mpv_initialize(m_mpv) < 0) { + qWarning(wpLog) << "WallpaperService: mpv_initialize failed"; + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + return; + } + } + + const QByteArray local = path.toUtf8(); + + initMpvRender(); + + const char* loadCmd[]{"loadfile", local.constData(), nullptr}; + if (mpv_command(m_mpv, loadCmd) < 0) + qWarning(wpLog) << "WallpaperService: mpv could not load" << path; + + m_mpvFrameReady.store(true, std::memory_order_relaxed); +} + +void WallpaperService::pumpMpvEvents() { + if (!m_mpv) return; + + mpv_event* event = nullptr; + while ((event = mpv_wait_event(m_mpv, 0)) != nullptr && + event->event_id != MPV_EVENT_NONE) { + if (event->event_id == MPV_EVENT_FILE_LOADED) { + m_videoLoaded.store(true, std::memory_order_relaxed); + } + + if (event->event_id == MPV_EVENT_END_FILE) { + m_videoLoaded.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + + auto* data = static_cast(event->data); + if (data && data->error != MPV_END_FILE_REASON_EOF) + qWarning(wpLog) << "WallpaperService: mpv ended the file, error" + << data->error; + } + } +} + +void WallpaperService::teardownMpv() { + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + + m_mpvFrameReady.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); +} + +void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) { + (void)ctx; + + if (strcmp(name, "glFenceSync") == 0) + return reinterpret_cast( + +[](GLenum, GLbitfield) -> GLsync { return nullptr; }); + + return reinterpret_cast(eglGetProcAddress(name)); +} + +void WallpaperService::mpvUpdate(void* ctx) { + auto* self = static_cast(ctx); + + self->m_mpvFrameReady.store(true, std::memory_order_relaxed); + + if (self->m_videoLoaded.load(std::memory_order_relaxed)) + self->m_videoReady.store(true, std::memory_order_relaxed); + + self->wake(); +} + +void WallpaperService::initMpvRender() { + if (m_mpvRender || !m_mpv) return; + + mpv_opengl_init_params gl_init{getMpvProcAddress, 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}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + const int rc = mpv_render_context_create(&m_mpvRender, m_mpv, params); + if (rc < 0) { + qWarning(wpLog) + << "WallpaperService: could not create mpv render context, code" + << rc; + m_mpvRender = nullptr; + return; + } + + mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this); +} + +void WallpaperService::renderMpvToSurface(const QSize& size) { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + glViewport(0, 0, size.width(), size.height()); + + mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA}; + + int flip = 1; + int block = 0; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(m_mpvRender, params); +} + +void WallpaperService::createBlackTexture(uint32_t& texture) { + if (texture != 0) return; + + const unsigned char pixel[4] = {0, 0, 0, 255}; + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel); +} + +void WallpaperService::start() { + { + std::lock_guard lock(m_mutex); + + if (started || m_path.isEmpty() || m_screens.isEmpty()) return; + + started = true; + } + + auto* interface = + qApp->nativeInterface(); + if (!interface) { + qWarning(wpLog) << "WallpaperService: this is not a Wayland session"; + return; + } + + m_display = interface->display(); + m_compositor = interface->compositor(); + if (!m_display || !m_compositor) return; + + m_queue = wl_display_create_queue(m_display); + if (!m_queue) return; + + if (pipe2(m_wake, O_CLOEXEC) < 0) { + qWarning(wpLog) + << "WallpaperService: could not make the pipe to wake on"; + return; + } + + bindGlobals(); + + if (!setupGl()) return; + + m_drawing = true; + m_thread = std::thread([this] { loop(); }); + + wake(); +} + +void WallpaperService::wake() { + if (m_wake[1] < 0) return; + + char byte = 0; + + if (::write(m_wake[1], &byte, 1) < 0) + qWarning(wpLog) + << "WallpaperService: could not wake the drawing thread"; +} + +void WallpaperService::queue(WallpaperSurface* surface) { + surface->markDirty(); + wake(); +} + +void WallpaperService::queueAll() { + { + std::lock_guard lock(m_mutex); + + for (auto* surface : m_surfaces) + surface->markDirty(); + } + + wake(); +} + +void WallpaperService::loop() { + eglBindAPI(kGlesMode ? EGL_OPENGL_ES_API : EGL_OPENGL_API); + if (m_context == EGL_NO_CONTEXT) { + if (kGlesMode) { + const EGLint contextAttributes[] = { + EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + } else { + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, nullptr); + } + if (m_context == EGL_NO_CONTEXT) { + qWarning(wpLog) << "WallpaperService: eglCreateContext failed"; + return; + } + } + + while (!m_stop) { + present(); + + if (wl_display_prepare_read(m_display) == 0) { + wl_display_flush(m_display); + + struct pollfd fds[2] = { + {wl_display_get_fd(m_display), POLLIN, 0}, + {m_wake[0], POLLIN, 0}, + }; + + int ready = poll(fds, 2, kWaitTimeoutMs); + + if (ready > 0 && fds[0].revents) { + wl_display_read_events(m_display); + continue; + } + + if (ready > 0 && fds[1].revents) { + char byte = 0; + ::read(m_wake[0], &byte, 1); + } + + wl_display_cancel_read(m_display); + continue; + } + + struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}}; + + if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue; + + char byte = 0; + ::read(m_wake[0], &byte, 1); + } +} + +void WallpaperService::present() { + wl_display_dispatch_queue_pending(m_display, m_queue); + + addSurfaces(); + + ensureImage(); + + ensurePoster(); + + if (m_isVideo && m_videoReady.load(std::memory_order_relaxed)) { + std::lock_guard lock(m_mutex); + m_replacementReady = true; + } + + QList surfaces; + bool videoReload = false; + QString path; + bool isVideo = false; + + { + std::lock_guard lock(m_mutex); + + videoReload = m_videoReload; + path = m_path; + isVideo = m_isVideo; + + const bool animating = !m_transitions.empty(); + const bool newVideoFrame = + m_isVideo && + m_mpvFrameReady.exchange(false, std::memory_order_relaxed); + + if (animating || newVideoFrame) + for (auto* surface : m_surfaces) + surface->markDirty(); + + surfaces = m_surfaces; + } + + if (m_context == EGL_NO_CONTEXT) return; + + if (!m_orphanTextures.empty()) { + std::lock_guard lock(m_mutex); + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + } + } + + EGLSurface importTarget = EGL_NO_SURFACE; + for (auto* surface : surfaces) + if (surface->eglSurface() != EGL_NO_SURFACE) { + importTarget = surface->eglSurface(); + break; + } + + if (importTarget == EGL_NO_SURFACE) return; + + if (!eglMakeCurrent(m_eglDisplay, importTarget, importTarget, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + if (!m_programsCompiled) { + m_programsCompiled = true; + std::lock_guard lock(m_mutex); + if (m_program == 0) m_program = createProgram(); + if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); + if (m_neutralProgram.id == 0) + m_neutralProgram = + programFor(QStringLiteral("wallpaper_trans_fade.frag")); + for (const QString& file : transitionFilesList()) + (void)programFor(file); + } + + if (videoReload) { + std::lock_guard lock(m_mutex); + m_videoReload = false; + } + + { + std::lock_guard lock(m_mutex); + + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + + const bool idle = m_transitions.empty() && !m_needCaptureBase && + !m_collapseVideo && !m_isVideo && m_mpv == nullptr +#ifdef ZHAVE_FFMPEG + && m_decoder == nullptr +#endif + ; + if (idle) { + if (m_baseTex) glDeleteTextures(1, &m_baseTex); + m_baseTex = 0; + m_baseSize = QSize(); + if (m_baseFbo) glDeleteFramebuffers(1, &m_baseFbo); + m_baseFbo = 0; + m_baseReady = false; + + if (m_accumA) glDeleteTextures(1, &m_accumA); + if (m_accumB) glDeleteTextures(1, &m_accumB); + m_accumA = 0; + m_accumB = 0; + if (m_accumFbo) glDeleteFramebuffers(1, &m_accumFbo); + m_accumFbo = 0; + m_accumSize = QSize(); + + if (m_captureTex) glDeleteTextures(1, &m_captureTex); + m_captureTex = 0; + if (m_captureFbo) glDeleteFramebuffers(1, &m_captureFbo); + m_captureFbo = 0; + m_captureSize = QSize(); + + if (m_posterTex) glDeleteTextures(1, &m_posterTex); + m_posterTex = 0; + m_posterReady = false; + m_posterChanged = false; + } + } + + const bool holdVideoForBase = (m_needCaptureBase && m_baseIsVideo) || + m_collapseVideo; + + auto applyVideoBackendSwitch = [&]() { + if (!videoReload) return; + + std::lock_guard lock(m_mutex); + + if (isVideo) { + startVideo(path); + } else if (m_mpv != nullptr) { + teardownMpv(); + } +#ifdef ZHAVE_FFMPEG + else if (m_decoder != nullptr) { + m_decoder->deleteTexture(); + stopDecoderVideo(); + } +#endif + }; + + if (!holdVideoForBase) applyVideoBackendSwitch(); + +#ifdef ZHAVE_FFMPEG + const bool videoActive = + (m_mpv != nullptr || m_decoder != nullptr) && + (m_isVideo || m_baseIsVideo || m_collapseVideo || m_promoteOldest); +#else + const bool videoActive = + m_mpv != nullptr && + (m_isVideo || m_baseIsVideo || m_collapseVideo || m_promoteOldest); +#endif + + if (videoActive && m_mpv != nullptr) pumpMpvEvents(); + +#ifdef ZHAVE_FFMPEG + if (videoActive && m_decoder != nullptr) m_decoder->importCurrentFrame(); +#endif + + for (auto* surface : surfaces) + drawSurface(surface); + + if (holdVideoForBase) applyVideoBackendSwitch(); + + { + std::lock_guard lock(m_mutex); + + if (m_collapseVideo) { + for (size_t i = 0; i + 1 < m_transitions.size(); ++i) + if (m_transitions[i].tex != 0) + m_orphanTextures.push_back(m_transitions[i].tex); + if (m_transitions.size() > 1) + m_transitions.erase( + m_transitions.begin(), m_transitions.end() - 1); + m_collapseVideo = false; + } + + if (m_promoteOldest && !m_transitions.empty()) { + if (m_transitions.front().tex != 0) + m_orphanTextures.push_back(m_transitions.front().tex); + m_transitions.erase(m_transitions.begin()); + m_promoteOldest = static_cast(m_transitions.size()) > + kMaxTransitions; + } + + if (m_newestDone) { + for (const auto& layer : m_transitions) + if (layer.tex != 0) m_orphanTextures.push_back(layer.tex); + m_transitions.clear(); + m_newestDone = false; + m_baseIsBlack = false; + m_baseImage = QImage(); + m_baseCrops = QVariantMap(); + } + + if (m_needCaptureBase) { + m_needCaptureBase = false; + m_baseIsVideo = false; + } + } +} + +void WallpaperService::addSurfaces() { + if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return; + + QList gone; + QList needed; + + { + std::lock_guard lock(m_mutex); + + QList kept; + for (auto* surface : m_surfaces) { + if (m_screens.contains(surface->screen())) + kept.append(surface); + else + gone.append(surface); + } + + m_surfaces = kept; + + for (const auto& screen : m_screens) { + bool exists = false; + for (auto* surface : m_surfaces) { + if (surface->screen() == screen) { + exists = true; + break; + } + } + + if (!exists) needed.append(screen); + } + } + + for (auto* surface : gone) + delete surface; + + for (const auto& screen : needed) + addSurface(screen); +} + +void WallpaperService::registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version) { + auto* service = static_cast(data); + + void* proxy = nullptr; + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) { + proxy = new QtWayland::zwlr_layer_shell_v1( + registry, int(name), int(version)); + } else if (strcmp(interface, "wp_viewporter") == 0) { + proxy = new QtWayland::wp_viewporter(registry, int(name), int(version)); + } else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) { + proxy = new QtWayland::wp_fractional_scale_manager_v1( + registry, int(name), int(version)); + } else { + return; + } + + service->storeGlobal(interface, proxy); + + service->queueAll(); +} + +void WallpaperService::storeGlobal(const char* interface, void* proxy) { + std::lock_guard lock(m_mutex); + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) + m_layerShell = static_cast(proxy); + else if (strcmp(interface, "wp_viewporter") == 0) + m_viewporter = static_cast(proxy); + else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) + m_scaleManager = + static_cast(proxy); +} + +void WallpaperService::bindGlobals() { + m_registry = wl_display_get_registry(m_display); + + static const struct wl_registry_listener listener = { + registryGlobal, + nullptr, + }; + + wl_registry_add_listener(m_registry, &listener, this); +} + +bool WallpaperService::setupGl() { + const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) { + m_eglDisplay = + eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr); + } else { + m_eglDisplay = + eglGetDisplay(reinterpret_cast(m_display)); + } + + if (m_eglDisplay == EGL_NO_DISPLAY) { + qWarning(wpLog) << "WallpaperService: no EGL display"; + return false; + } + + EGLint major = 0, minor = 0; + if (!eglInitialize(m_eglDisplay, &major, &minor)) { + qWarning(wpLog) << "WallpaperService: eglInitialize failed"; + return false; + } + + const EGLint configAttributes[] = { + EGL_SURFACE_TYPE, + EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + kGlesMode ? EGL_OPENGL_ES2_BIT : (EGL_OPENGL_BIT | EGL_OPENGL_ES2_BIT), + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_NONE}; + + EGLint count = 0; + if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || + count == 0) { + qWarning(wpLog) + << "WallpaperService: no EGL config for window surfaces"; + return false; + } + + return true; +} + +uint32_t WallpaperService::createProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "source") < 0) { + qWarning(wpLog) << "WallpaperService: the program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_sizeUniform = glGetUniformLocation(program, "size"); + m_cropUniform = glGetUniformLocation(program, "crop"); + m_alphaUniform = glGetUniformLocation(program, "alpha"); + m_sourceUniform = glGetUniformLocation(program, "source"); + + glUseProgram(program); + glUniform1i(GLint(m_sourceUniform), 0); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +WallpaperService::TransProgram WallpaperService::createTransitionProgram( + const QString& file) { + TransProgram p; + + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource(file, true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "from") < 0 || + glGetUniformLocation(program, "to") < 0 || + glGetUniformLocation(program, "fromCrop") < 0 || + glGetUniformLocation(program, "toCrop") < 0 || + glGetUniformLocation(program, "fromFlip") < 0 || + glGetUniformLocation(program, "toFlip") < 0 || + glGetUniformLocation(program, "progress") < 0) { + qWarning(wpLog) << "WallpaperService: the transition program does not " + "have the uniforms it needs"; + glDeleteProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + return p; + } + + p.id = program; + p.size = glGetUniformLocation(program, "size"); + p.progress = glGetUniformLocation(program, "progress"); + p.from = glGetUniformLocation(program, "from"); + p.to = glGetUniformLocation(program, "to"); + p.fromCrop = glGetUniformLocation(program, "fromCrop"); + p.toCrop = glGetUniformLocation(program, "toCrop"); + p.fromFlip = glGetUniformLocation(program, "fromFlip"); + p.toFlip = glGetUniformLocation(program, "toFlip"); + + glUseProgram(program); + glUniform1i(GLint(p.from), 0); + glUniform1i(GLint(p.to), 1); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return p; +} + +WallpaperService::TransProgram WallpaperService::programFor( + const QString& file) { + auto cached = m_transPrograms.constFind(file); + if (cached != m_transPrograms.constEnd()) return cached.value(); + + TransProgram p = createTransitionProgram(file); + if (p.id != 0) m_transPrograms.insert(file, p); + return p; +} + +bool WallpaperService::ensureTransitionProgram() { + if (m_neutralProgram.id == 0) + m_neutralProgram = + programFor(QStringLiteral("wallpaper_trans_fade.frag")); + + return m_neutralProgram.id != 0; +} + +QString WallpaperService::transitionFileFor( + ZShell::config::WallpaperTransition::Enum value) { + if (value == ZShell::config::WallpaperTransition::Random) + return randomTransitionFile(); + return QString(transitionFile(value)); +} + +QString WallpaperService::randomTransitionFile() { + const QStringList& files = transitionFilesList(); + if (files.isEmpty()) return QStringLiteral("wallpaper_trans_fade.frag"); + if (files.size() == 1) return files.at(0); + + if (m_transitionUses.size() != files.size()) + m_transitionUses.fill(0, files.size()); + + double total = 0.0; + QVector weights(files.size(), 0.0); + for (int i = 0; i < files.size(); ++i) { + if (files.at(i) == m_lastTransition) continue; + weights[i] = 1.0 / double(m_transitionUses.at(i) + 1); + total += weights[i]; + } + + if (total <= 0.0) return files.at(0); + + double pick = QRandomGenerator::global()->generateDouble() * total; + int chosen = -1; + for (int i = 0; i < files.size(); ++i) { + if (weights[i] <= 0.0) continue; + pick -= weights[i]; + if (pick <= 0.0) { + chosen = i; + break; + } + } + if (chosen < 0) chosen = int(files.size()) - 1; + + ++m_transitionUses[chosen]; + m_lastTransition = files.at(chosen); + return files.at(chosen); +} + +void WallpaperService::ensureCapture(const QSize& size) { + if (m_captureFbo == 0) { + glGenFramebuffers(1, &m_captureFbo); + glGenTextures(1, &m_captureTex); + + glBindTexture(GL_TEXTURE_2D, m_captureTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + + if (m_captureSize != size) { + glBindTexture(GL_TEXTURE_2D, m_captureTex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + m_captureSize = size; + } + + glBindFramebuffer(GL_FRAMEBUFFER, m_captureFbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_captureTex, 0); + + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) + qWarning(wpLog) << "WallpaperService: the capture framebuffer is " + "incomplete"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +#ifdef ZHAVE_FFMPEG +uint32_t WallpaperService::createVideoProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper_video.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "srcSize") < 0 || + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "luma") < 0 || + glGetUniformLocation(program, "chroma") < 0 || + glGetUniformLocation(program, "matrix") < 0 || + glGetUniformLocation(program, "range") < 0 || + glGetUniformLocation(program, "chromaOff") < 0) { + qWarning(wpLog) + << "WallpaperService: the video program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_videoSizeUniform = glGetUniformLocation(program, "size"); + m_videoSrcSizeUniform = glGetUniformLocation(program, "srcSize"); + m_videoCropUniform = glGetUniformLocation(program, "crop"); + m_videoAlphaUniform = glGetUniformLocation(program, "alpha"); + m_videoLumaUniform = glGetUniformLocation(program, "luma"); + m_videoChromaUniform = glGetUniformLocation(program, "chroma"); + m_videoMatrixUniform = glGetUniformLocation(program, "matrix"); + m_videoRangeUniform = glGetUniformLocation(program, "range"); + m_videoChromaOffUniform = glGetUniformLocation(program, "chromaOff"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, lumaTex); + glUniform1i(GLint(m_videoLumaUniform), 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, chromaTex); + glUniform1i(GLint(m_videoChromaUniform), 1); + + glUniform1i(GLint(m_videoMatrixUniform), matrix); + glUniform1i(GLint(m_videoRangeUniform), range); + + glUniform2f( + GLint(m_videoChromaOffUniform), + range == 0 && srcW > 0 ? 0.5f / srcW : 0.0f, + 0.0f); + + glUniform2f(GLint(m_videoSizeUniform), sizeW, sizeH); + glUniform2f(GLint(m_videoSrcSizeUniform), srcW, srcH); + glUniform4f( + GLint(m_videoCropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_videoAlphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +bool WallpaperService::startDecoderVideo(const QString& path) { + if (!m_decoder) + m_decoder = createVideoDecoder(m_eglDisplay, backendFromConfig()); + if (!m_decoder) return false; + + m_decoder->setReadyCallback([this]() { + m_videoLoaded.store(true, std::memory_order_relaxed); + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + m_decoder->setFrameCallback([this]() { + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + if (!m_decoder->start(path)) return false; + + return true; +} + +void WallpaperService::stopDecoderVideo() { + if (!m_decoder) return; + + m_decoder->stop(); + m_decoder.reset(); + + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); +} +#endif + +uint32_t WallpaperService::renderVideoIntoTexture(const QSize& size) { + ensureCapture(size); + if (m_captureFbo == 0 || m_captureTex == 0) return 0; + + glBindFramebuffer(GL_FRAMEBUFFER, m_captureFbo); + glViewport(0, 0, size.width(), size.height()); + glDisable(GL_BLEND); + +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { + if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_decoder->frameSize(); + drawVideoTexture( + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + float(size.width()), + float(size.height()), + src.width() > 0 ? float(src.width()) : float(size.width()), + src.height() > 0 ? float(src.height()) + : float(size.height()), + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + return m_captureTex; + } + + glViewport(0, 0, size.width(), size.height()); + + mpv_opengl_fbo fbo{ + int(m_captureFbo), size.width(), size.height(), GL_RGBA}; + + int flip = 1; + int block = 0; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(m_mpvRender, params); + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + return m_captureTex; +} + + +void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { + if (image.isNull()) return; + + if (texture == 0) { + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + } else { + glBindTexture(GL_TEXTURE_2D, texture); + } + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + image.width(), + image.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + image.constBits()); +} + +void WallpaperService::uploadImage() { + uploadTexture(m_texture, m_image); +} + +void WallpaperService::drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(GLint(m_sourceUniform), 0); + glUniform2f(GLint(m_sizeUniform), sizeW, sizeH); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_alphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +void WallpaperService::ensureImage() { + QSize largest; + QString path; + bool needDecode = false; + + { + std::lock_guard lock(m_mutex); + + if (m_isVideo) return; + + largest = largestBufferSize(); + if (largest.isEmpty()) return; + + path = m_path; + + if (m_stagedReady && m_stagedPath == path) { + m_image = m_stagedImage; + m_stagedImage = QImage(); + m_stagedReady = false; + m_decodeSize = largest; + m_imageChanged = true; + m_replacementReady = true; + } else { + m_stagedReady = false; + m_stagedImage = QImage(); + + const bool grew = m_decodeSize.isEmpty() || + largest.width() > m_decodeSize.width() || + largest.height() > m_decodeSize.height(); + + if (!m_decoding && (m_forceDecode || path != m_decodePath || + m_image.isNull() || grew)) { + m_forceDecode = false; + m_decoding = true; + m_decodePath = path; + needDecode = true; + } else { + m_forceDecode = false; + } + } + } + + if (!needDecode) return; + + QThreadPool::globalInstance()->start([this, path, largest]() { + const QImage image = m_cache.decodeImage(path, largest); + + std::lock_guard lock(m_mutex); + m_stagedImage = image; + m_stagedPath = path; + m_stagedReady = !image.isNull(); + m_decoding = false; + }); + + wake(); +} + +void WallpaperService::ensurePoster() { + QString path; + + { + std::lock_guard lock(m_mutex); + + if (!m_isVideo) return; + + path = m_path; + + if (m_posterPath == path && (m_posterReady || m_posterLoading)) return; + if (m_posterLoading) return; + + m_posterPath = path; + m_posterReady = false; + m_posterLoading = true; + m_posterImage = QImage(); + } + + QThreadPool::globalInstance()->start([this, path]() { + const QByteArray blob = m_cache.posterBlob(path); + + QImage image; + if (!blob.isEmpty()) { + image = QImage::fromData(blob, "JPEG") + .convertToFormat(QImage::Format_RGBA8888); + } + + std::lock_guard lock(m_mutex); + m_posterImage = image; + m_posterReady = !image.isNull(); + m_posterChanged = m_posterReady; + m_posterLoading = false; + + if (!m_posterReady) + qWarning(wpLog) + << "WallpaperService: poster decode failed for" << path; + }); + + wake(); +} + +void WallpaperService::invalidateCache() { + { + std::lock_guard lock(m_mutex); + if (m_isVideo) return; + m_forceDecode = true; + } + + wake(); +} + +QSize WallpaperService::largestBufferSize() const { + std::lock_guard lock(m_mutex); + + QSize largest; + + for (auto* surface : m_surfaces) { + QSize buffer = surface->bufferSize(); + if (buffer.isEmpty()) continue; + + if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() > + qint64(largest.width()) * largest.height()) + largest = buffer; + } + + return largest; +} + +void WallpaperService::notifyBufferSizes() { + QMetaObject::invokeMethod( + this, "largestBufferSizeChanged", Qt::QueuedConnection); +} + +QRectF WallpaperService::sourceRect( + const QVariantMap& crops, + const QString& screen, + const QSize& size, + const QSize& image) const { + QVariantMap crop = cropFor(crops, screen); + + QRectF rect( + crop.value("x", 0.0).toDouble(), + crop.value("y", 0.0).toDouble(), + crop.value("width", 1.0).toDouble(), + crop.value("height", 1.0).toDouble()); + + if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect; + + qreal imageWidth = qreal(image.width()); + qreal imageHeight = qreal(image.height()); + + QRectF pixels( + rect.x() * imageWidth, + rect.y() * imageHeight, + rect.width() * imageWidth, + rect.height() * imageHeight); + + qreal targetRatio = qreal(size.width()) / qreal(size.height()); + qreal rectRatio = pixels.width() / pixels.height(); + + if (rectRatio > targetRatio) { + qreal newWidth = pixels.height() * targetRatio; + pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0); + pixels.setWidth(newWidth); + } else if (rectRatio < targetRatio) { + qreal newHeight = pixels.width() / targetRatio; + pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0); + pixels.setHeight(newHeight); + } + + return QRectF( + pixels.x() / imageWidth, + pixels.y() / imageHeight, + pixels.width() / imageWidth, + pixels.height() / imageHeight); +} + +void WallpaperService::drawSurface(WallpaperSurface* surface) { + if (!m_transitions.empty()) { + drawSurfaceTransition(surface); + return; + } + + if (!surface->needsDraw() || !surface->frameReady()) return; + + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + eglSwapInterval(m_eglDisplay, 0); + + if (m_vao == 0) { + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + } + + struct ContextRelease { + EGLDisplay display; + ~ContextRelease() { + eglMakeCurrent( + display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + } contextRelease{m_eglDisplay}; + + QVariantMap crops; + QSize size; + + { + std::lock_guard lock(m_mutex); + + crops = m_crops; + size = surface->bufferSize(); + + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_program == 0) return; + + const bool videoNow = m_isVideo; + + if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; + +#ifdef ZHAVE_FFMPEG + const bool videoAsBackground = + videoNow && (m_mpv != nullptr || m_decoder != nullptr); +#else + const bool videoAsBackground = videoNow && m_mpv != nullptr; +#endif + + if (videoAsBackground) { + glDisable(GL_BLEND); + glViewport(0, 0, size.width(), size.height()); + +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { + if (m_videoProgram == 0) + m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_decoder->frameSize(); + drawVideoTexture( + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + float(size.width()), + float(size.height()), + src.width() > 0 ? float(src.width()) + : float(size.width()), + src.height() > 0 ? float(src.height()) + : float(size.height()), + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + renderMpvToSurface(size); + } + } else { + glUseProgram(m_program); + glViewport(0, 0, size.width(), size.height()); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + } + + if (!videoNow) { + const bool upload = m_imageChanged; + m_imageChanged = false; + + if (m_texture == 0 || upload) uploadImage(); + + if (m_texture == 0 || m_image.isNull()) return; + } + + glUseProgram(m_program); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + const float w = float(size.width()); + const float h = float(size.height()); + + if (!videoNow) { + const QRectF rect = + sourceRect(crops, surface->screen(), size, m_image.size()); + drawTexture(m_texture, rect, w, h, 1.0f); + } + } + + surface->requestFrame(); + + if (!eglSwapBuffers(m_eglDisplay, target)) { + qWarning(wpLog) + << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" + << QString::number(unsigned(eglGetError()), 16); + return; + } + + if (m_isVideo && m_mpvRender) mpv_render_context_report_swap(m_mpvRender); + + surface->endDraw(); +} + +void WallpaperService::ensureBaseFbo() { + if (m_baseFbo != 0) return; + + glGenFramebuffers(1, &m_baseFbo); + glGenTextures(1, &m_baseTex); + + glBindTexture(GL_TEXTURE_2D, m_baseTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); +} + +void WallpaperService::ensureAccum(const QSize& size) { + if (m_accumFbo == 0) { + glGenFramebuffers(1, &m_accumFbo); + glGenTextures(1, &m_accumA); + glGenTextures(1, &m_accumB); + + for (uint32_t tex : {m_accumA, m_accumB}) { + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + } + + if (m_accumSize != size) { + for (uint32_t tex : {m_accumA, m_accumB}) { + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + } + m_accumSize = size; + } +} + +void WallpaperService::bindRtt(uint32_t fbo, uint32_t tex, const QSize& size) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + glViewport(0, 0, size.width(), size.height()); +} + +void WallpaperService::drawTransitionPair( + const TransProgram& program, + uint32_t fromTex, + float fromFlip, + const QRectF& fromCrop, + uint32_t toTex, + float toFlip, + const QRectF& toCrop, + qreal progress, + const QSize& size) { + glUseProgram(program.id); + glDisable(GL_BLEND); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, fromTex); + glUniform1i(GLint(program.from), 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, toTex); + glUniform1i(GLint(program.to), 1); + + glUniform2f(GLint(program.size), float(size.width()), float(size.height())); + glUniform4f( + GLint(program.fromCrop), + GLfloat(fromCrop.x()), + GLfloat(fromCrop.y()), + GLfloat(fromCrop.width()), + GLfloat(fromCrop.height())); + glUniform4f( + GLint(program.toCrop), + GLfloat(toCrop.x()), + GLfloat(toCrop.y()), + GLfloat(toCrop.width()), + GLfloat(toCrop.height())); + glUniform1f(GLint(program.fromFlip), fromFlip); + glUniform1f(GLint(program.toFlip), toFlip); + glUniform1f(GLint(program.progress), float(progress)); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +uint32_t WallpaperService::targetTexFor( + const TransitionLayer& layer, int index) const { + if (layer.isVideo) return m_captureTex; + if (layer.tex != 0) return layer.tex; + Q_UNUSED(index); + return m_texture; +} + +QRectF WallpaperService::cropForLayer( + const TransitionLayer& layer, + const QString& screen, + const QSize& size) const { + if (layer.isVideo) return QRectF(0.0, 0.0, 1.0, 1.0); + if (layer.tex != 0) + return sourceRect(layer.crops, screen, size, layer.image.size()); + return sourceRect(m_crops, screen, size, m_image.size()); +} + +void WallpaperService::captureBase( + const QSize& size, + const QString& screen, + uint32_t oldTex, + float oldFlip, + const QRectF& oldCrop) { + Q_UNUSED(screen); + + ensureBaseFbo(); + if (m_baseTex == 0) return; + + Q_UNUSED(oldCrop); + if (!m_baseImage.isNull()) { + uploadTexture(m_baseTex, m_baseImage); + m_baseSize = m_baseImage.size(); + m_baseReady = true; + return; + } + + if (m_baseSize != size) { + glBindTexture(GL_TEXTURE_2D, m_baseTex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + m_baseSize = size; + } + + bindRtt(m_baseFbo, m_baseTex, size); + drawTransitionPair( + m_neutralProgram, + oldTex, + oldFlip, + QRectF(0.0, 0.0, 1.0, 1.0), + oldTex, + oldFlip, + QRectF(0.0, 0.0, 1.0, 1.0), + 1.0, + size); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + m_baseReady = true; +} + +void WallpaperService::renderComposite( + const QSize& size, + const QString& screen, + uint32_t outFbo, + uint32_t outTex, + int first, + int last, + uint32_t videoTex, + uint32_t posterTex) { + if (m_baseTex == 0) return; + + ensureAccum(size); + + float fromFlip = 0.0f; + QRectF fromCrop = QRectF(0.0, 0.0, 1.0, 1.0); + if (!m_baseImage.isNull()) { + fromFlip = 1.0f; + fromCrop = sourceRect(m_baseCrops, screen, size, m_baseImage.size()); + } + + bindRtt(m_accumFbo, m_accumA, size); + drawTransitionPair( + m_neutralProgram, + m_baseTex, + fromFlip, + fromCrop, + m_baseTex, + fromFlip, + fromCrop, + 1.0, + size); + + uint32_t read = m_accumA; + uint32_t write = m_accumB; + int drawn = 0; + + for (int i = first; i < last; ++i) { + const TransitionLayer& layer = m_transitions[size_t(i)]; + if (!layer.ready) continue; + + uint32_t toTex = 0; + float toFlip = 1.0f; + QRectF toCrop(0.0, 0.0, 1.0, 1.0); + + if (layer.isVideo) { + if (m_videoReady.load(std::memory_order_relaxed) && videoTex != 0) { + toTex = videoTex; + toFlip = 0.0f; + } else if (posterTex != 0) { + toTex = posterTex; + toFlip = 1.0f; + toCrop = + sourceRect(layer.crops, screen, size, m_posterImage.size()); + } else { + toTex = videoTex; + toFlip = 0.0f; + } + } else { + toTex = targetTexFor(layer, i); + toCrop = cropForLayer(layer, screen, size); + } + + if (toTex == 0) continue; + + const TransProgram program = layer.transitionFile.isEmpty() + ? m_neutralProgram + : programFor(layer.transitionFile); + + bindRtt(m_accumFbo, write, size); + drawTransitionPair( + program, + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + toTex, + toFlip, + toCrop, + layer.progress, + size); + + std::swap(read, write); + ++drawn; + } + + if (drawn == 0) { + if (outFbo == m_baseFbo) return; + read = m_accumA; + } + + if (outFbo == 0) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + } else { + bindRtt(outFbo, outTex, size); + } + + drawTransitionPair( + m_neutralProgram, + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + 1.0, + size); +} + +void WallpaperService::drawSurfaceTransition(WallpaperSurface* surface) { + if (!surface->needsDraw() || !surface->frameReady()) return; + + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + eglSwapInterval(m_eglDisplay, 0); + + if (m_vao == 0) { + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + } + + struct ContextRelease { + EGLDisplay display; + ~ContextRelease() { + eglMakeCurrent( + display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + } contextRelease{m_eglDisplay}; + + { + std::lock_guard lock(m_mutex); + + const QSize size = surface->bufferSize(); + if (size.isEmpty()) return; + const QString screen = surface->screen(); + + if (m_transitions.empty()) return; + + if (!ensureTransitionProgram()) return; + if (m_blackTex == 0) createBlackTexture(m_blackTex); + + ensureBaseFbo(); + + const bool newestVideoLive = + !m_transitions.empty() && m_transitions.back().isVideo && + m_videoReady.load(std::memory_order_relaxed); + + bool anyVideoLayer = false; + for (const auto& layer : m_transitions) + if (layer.isVideo) { + anyVideoLayer = true; + break; + } + + bool needVideo = + (m_needCaptureBase && m_baseIsVideo) || + (m_videoReady.load(std::memory_order_relaxed) && anyVideoLayer); + + const uint32_t videoTex = needVideo ? renderVideoIntoTexture(size) : 0; + + if (m_needCaptureBase && m_baseIsVideo && videoTex == 0) return; + + if (m_needCaptureBase) { + uint32_t oldTex = 0; + float oldFlip = 0.0f; + QRectF oldCrop(0.0, 0.0, 1.0, 1.0); + + if (m_baseIsBlack) { + oldTex = m_blackTex; + oldFlip = 1.0f; + } else if (m_baseIsVideo) { + oldTex = videoTex; + oldFlip = 0.0f; + } else { + oldTex = m_texture; + oldFlip = 1.0f; + oldCrop = + sourceRect(m_baseCrops, screen, size, m_baseImage.size()); + } + + if (oldTex != 0) + captureBase(size, screen, oldTex, oldFlip, oldCrop); + } + + if (!m_transitions.back().isVideo) { + const bool upload = m_imageChanged; + m_imageChanged = false; + if (m_texture == 0 || upload) uploadImage(); + } else { + m_imageChanged = false; + } + + if (m_isVideo && m_posterChanged) { + m_posterChanged = false; + if (!m_posterImage.isNull()) + uploadTexture(m_posterTex, m_posterImage); + } + + const bool newestVideoPoster = newestVideoLive == false && + m_transitions.back().isVideo && + m_posterReady && m_posterTex != 0; + + const bool newestReady = + m_isVideo + ? (newestVideoLive || newestVideoPoster) + : (!m_image.isNull() && m_texture != 0 && m_replacementReady); + + for (size_t i = 0; i < m_transitions.size(); ++i) { + TransitionLayer& layer = m_transitions[i]; + const bool ready = + (i + 1 == m_transitions.size()) + ? newestReady + : (layer.isVideo + ? (layer.ready || m_videoReady || m_posterReady) + : layer.tex != 0); + if (!ready) continue; + + layer.ready = true; + if (!layer.started) { + layer.started = true; + layer.timer.start(); + } + layer.progress = + qreal(layer.timer.elapsed()) / qreal(kFadeDurationMs); + if (layer.progress > 1.0) layer.progress = 1.0; + } + + const bool newestSettled = + m_transitions.back().isVideo ? newestVideoLive : true; + + const bool collapse = m_collapseVideo && m_transitions.size() > 1; + const bool promote = m_promoteOldest && !m_transitions.empty(); + + if (collapse) { + ensureAccum(size); + renderComposite( + size, + screen, + m_baseFbo, + m_baseTex, + 0, + int(m_transitions.size()) - 1, + videoTex, + m_posterTex); + } else if (promote) { + ensureAccum(size); + renderComposite( + size, screen, m_baseFbo, m_baseTex, 0, 1, videoTex, m_posterTex); + } + + int first = 0; + if (collapse) + first = int(m_transitions.size()) - 1; + else if (promote) + first = 1; + + const int last = int(m_transitions.size()); + const TransitionLayer& newest = m_transitions.back(); + + if (m_baseIsBlack) { + const qreal p = newest.ready ? newest.progress : 0.0; + const float a = p < 0.0 ? 0.0f : (p > 1.0 ? 1.0f : float(p)); + const float w = float(size.width()); + const float h = float(size.height()); + + if (m_program == 0) m_program = createProgram(); + if (m_program == 0) return; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + + if (m_isVideo) { + if (!m_videoReady.load(std::memory_order_relaxed)) { + if (!m_posterReady || m_posterTex == 0) return; + + glUseProgram(m_program); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + drawTexture( + m_posterTex, + sourceRect(m_crops, screen, size, m_posterImage.size()), + w, + h, + a); + } else { + glDisable(GL_BLEND); + +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { + if (m_videoProgram == 0) + m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_decoder->frameSize(); + drawVideoTexture( + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + src.width() > 0 ? float(src.width()) : w, + src.height() > 0 ? float(src.height()) : h, + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + renderMpvToSurface(size); + } + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glUseProgram(m_program); + + if (m_blackTex != 0) + drawTexture( + m_blackTex, + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + 1.0f - a); + } + } else { + if (m_texture == 0 || m_image.isNull()) return; + + glUseProgram(m_program); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + drawTexture( + m_texture, + sourceRect(m_crops, screen, size, m_image.size()), + w, + h, + a); + } + + if (newest.ready && newest.progress >= 1.0 && newestSettled) + m_newestDone = true; + } else { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + + if (!m_baseReady) { + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + } else { + renderComposite( + size, screen, 0, 0, first, last, videoTex, m_posterTex); + } + + if (newest.ready && newest.progress >= 1.0 && newestSettled) + m_newestDone = true; + } + } + + surface->requestFrame(); + + if (!eglSwapBuffers(m_eglDisplay, target)) { + qWarning(wpLog) + << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" + << QString::number(unsigned(eglGetError()), 16); + return; + } + + if ((m_isVideo || m_baseIsVideo) && m_mpvRender) + mpv_render_context_report_swap(m_mpvRender); + + surface->endDraw(); +} + +void WallpaperService::addSurface(const QString& screen) { + if (!m_layerShell || !m_viewporter || !m_scaleManager) return; + + QScreen* qscreen = nullptr; + for (auto* candidate : QGuiApplication::screens()) { + if (candidate->name() == screen) { + qscreen = candidate; + break; + } + } + + if (!qscreen) { + qWarning(wpLog) << "WallpaperService:" << screen << "is not a screen"; + return; + } + + auto* waylandScreen = + dynamic_cast(qscreen->handle()); + if (!waylandScreen || !waylandScreen->output()) { + qWarning(wpLog) << "WallpaperService:" << screen + << "is not a Wayland screen"; + return; + } + + wl_surface* wlSurface = wl_compositor_create_surface(m_compositor); + if (!wlSurface) return; + + auto* surface = + new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen); + + { + std::lock_guard lock(m_mutex); + m_surfaces.append(surface); + } + + notifyBufferSizes(); +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpaperservice.hpp b/Plugins/ZShell/Wallpaper/wallpaperservice.hpp new file mode 100644 index 0000000..60c8073 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpaperservice.hpp @@ -0,0 +1,367 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "videodecoder.hpp" +#include "wallpapercache.hpp" +#include "videotranscoder.hpp" + +#include +#include + +#include "wallpapersurface.hpp" +#include "config/enums.hpp" + +struct mpv_handle; +struct mpv_render_context; + +class QProcess; + +namespace ZShell::wallpaper { + +class WallpaperSurface; + +class WallpaperService : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + QSize largestBufferSize READ largestBufferSize NOTIFY + largestBufferSizeChanged) + + public: + explicit WallpaperService(QObject* parent = nullptr); + ~WallpaperService() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QSize largestBufferSize() const; + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + + void addScreen(const QString& screen); + void removeScreen(const QString& screen); + + [[nodiscard]] const QVariantMap& crops() const; + void setCrops(const QVariantMap& crops); + + Q_INVOKABLE void redraw(); + + Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + + Q_INVOKABLE void invalidateCache(); + + signals: + void pathChanged(); + void cropsChanged(); + void dbPathChanged(); + void largestBufferSizeChanged(); + + private: + friend class WallpaperSurface; + + static bool isVideoPath(const QString& path); + + void applyPath(const QString& path); + + void startVideo(const QString& path); + void teardownMpv(); + + void initMpvRender(); + void renderMpvToSurface(const QSize& size); + void pumpMpvEvents(); + + static void* getMpvProcAddress(void* ctx, const char* name); + static void mpvUpdate(void* ctx); + +#ifdef ZHAVE_FFMPEG + bool startDecoderVideo(const QString& path); + void stopDecoderVideo(); + [[nodiscard]] uint32_t createVideoProgram(); + void drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha); +#endif + + void bindGlobals(); + static void registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version); + + void storeGlobal(const char* interface, void* proxy); + + struct TransProgram { + uint32_t id = 0; + int size = -1; + int progress = -1; + int from = -1; + int to = -1; + int fromCrop = -1; + int toCrop = -1; + int fromFlip = -1; + int toFlip = -1; + }; + + [[nodiscard]] bool setupGl(); + [[nodiscard]] uint32_t createProgram(); + [[nodiscard]] TransProgram createTransitionProgram(const QString& file); + [[nodiscard]] TransProgram programFor(const QString& file); + bool ensureTransitionProgram(); + QString transitionFileFor(ZShell::config::WallpaperTransition::Enum value); + [[nodiscard]] QString randomTransitionFile(); + + void ensureCapture(const QSize& size); + uint32_t renderVideoIntoTexture(const QSize& size); + + void drawSurfaceTransition(WallpaperSurface* surface); + + struct TransitionLayer { + uint32_t tex = 0; + QImage image; + QVariantMap crops; + QString transitionFile; + bool isVideo = false; + bool ready = false; + bool started = false; + QElapsedTimer timer; + qreal progress = 0.0; + }; + + void ensureBaseFbo(); + void ensureAccum(const QSize& size); + void bindRtt(uint32_t fbo, uint32_t tex, const QSize& size); + void drawTransitionPair( + const TransProgram& program, + uint32_t fromTex, + float fromFlip, + const QRectF& fromCrop, + uint32_t toTex, + float toFlip, + const QRectF& toCrop, + qreal progress, + const QSize& size); + uint32_t targetTexFor(const TransitionLayer& layer, int index) const; + QRectF cropForLayer( + const TransitionLayer& layer, + const QString& screen, + const QSize& size) const; + void captureBase( + const QSize& size, + const QString& screen, + uint32_t oldTex, + float oldFlip, + const QRectF& oldCrop); + void renderComposite( + const QSize& size, + const QString& screen, + uint32_t outFbo, + uint32_t outTex, + int first, + int last, + uint32_t videoTex, + uint32_t posterTex); + + void uploadTexture(uint32_t& texture, const QImage& image); + void uploadImage(); + void createBlackTexture(uint32_t& texture); + + void drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha); + + void ensureImage(); + + void ensurePoster(); + + void notifyBufferSizes(); + + void queue(WallpaperSurface* surface); + void queueAll(); + + void loop(); + + void present(); + + void drawSurface(WallpaperSurface* surface); + + void wake(); + + [[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; } + + [[nodiscard]] QRectF sourceRect( + const QVariantMap& crops, + const QString& screen, + const QSize& size, + const QSize& image) const; + + void start(); + + void addSurfaces(); + void addSurface(const QString& screen); + + QString m_path; + QString m_dbPath; + QStringList m_screens; + QVariantMap m_crops; + + WallpaperCache m_cache; + + QImage m_image; + + bool m_isVideo = false; + bool m_videoReload = false; + mpv_handle* m_mpv = nullptr; + mpv_render_context* m_mpvRender = nullptr; + uint32_t m_blackTex = 0; + std::atomic m_mpvFrameReady{false}; + std::atomic m_videoReady{false}; + std::atomic m_videoLoaded{false}; + + VideoTranscoder* m_transcoder = nullptr; + + QSize m_decodeSize; + + QImage m_stagedImage; + QString m_stagedPath; + bool m_stagedReady = false; + bool m_decoding = false; + QString m_decodePath; + bool m_replacementReady = false; + + wl_display* m_display = nullptr; + wl_registry* m_registry = nullptr; + wl_compositor* m_compositor = nullptr; + QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr; + QtWayland::wp_viewporter* m_viewporter = nullptr; + QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr; + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLConfig m_config = nullptr; + EGLContext m_context = EGL_NO_CONTEXT; + uint32_t m_texture = 0; + uint32_t m_program = 0; + int m_sizeUniform = -1; + int m_cropUniform = -1; + int m_alphaUniform = -1; + int m_sourceUniform = -1; + + bool m_randomTransition = false; + QString m_transitionFile; + bool m_transitionDirty = false; + QHash m_transPrograms; + TransProgram m_neutralProgram; + QVector m_transitionUses; + QString m_lastTransition; + + uint32_t m_captureFbo = 0; + uint32_t m_captureTex = 0; + QSize m_captureSize; + + uint32_t m_baseFbo = 0; + uint32_t m_baseTex = 0; + QSize m_baseSize; + bool m_baseReady = false; + + uint32_t m_accumFbo = 0; + uint32_t m_accumA = 0; + uint32_t m_accumB = 0; + QSize m_accumSize; + + std::vector m_transitions; + + bool m_needCaptureBase = false; + QImage m_baseImage; + QVariantMap m_baseCrops; + bool m_baseIsVideo = false; + bool m_baseIsBlack = false; + bool m_collapseVideo = false; + bool m_promoteOldest = false; + bool m_newestDone = false; + + static constexpr int kMaxTransitions = 4; + + uint32_t m_videoProgram = 0; + uint32_t m_vao = 0; + int m_videoSizeUniform = -1; + int m_videoSrcSizeUniform = -1; + int m_videoCropUniform = -1; + int m_videoAlphaUniform = -1; + int m_videoLumaUniform = -1; + int m_videoChromaUniform = -1; + int m_videoMatrixUniform = -1; + int m_videoRangeUniform = -1; + int m_videoChromaOffUniform = -1; + +#ifdef ZHAVE_FFMPEG + std::unique_ptr m_decoder; + bool m_useDecoder = true; +#endif + + std::vector m_orphanTextures; + + bool m_programsCompiled = false; + + QList m_surfaces; + + std::thread m_thread; + mutable std::recursive_mutex m_mutex; + wl_event_queue* m_queue = nullptr; + + int m_wake[2] = {-1, -1}; + + bool m_stop = false; + bool m_drawing = false; + + bool started = false; + + bool m_forceDecode = false; + bool m_imageChanged = false; + + QImage m_posterImage; + uint32_t m_posterTex = 0; + QString m_posterPath; + bool m_posterReady = false; + bool m_posterLoading = false; + bool m_posterChanged = false; +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Wallpaper/wallpapersurface.cpp similarity index 74% rename from Plugins/ZShell/Internal/wallpapersurface.cpp rename to Plugins/ZShell/Wallpaper/wallpapersurface.cpp index 8fe99e6..551b610 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Wallpaper/wallpapersurface.cpp @@ -6,7 +6,7 @@ #include "wallpaperservice.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { const QString kNamespace = "ZShell-Wallpaper"; @@ -53,6 +53,11 @@ WallpaperSurface::WallpaperSurface( } WallpaperSurface::~WallpaperSurface() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + if (m_eglSurface != EGL_NO_SURFACE) eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); @@ -68,8 +73,45 @@ WallpaperSurface::~WallpaperSurface() { if (m_surface) wl_surface_destroy(m_surface); } -void WallpaperSurface::redraw() { - m_service->queue(this); +void WallpaperSurface::markDirty() { + m_dirty.store(true, std::memory_order_relaxed); +} + +bool WallpaperSurface::needsDraw() const { + return m_dirty.load(std::memory_order_relaxed); +} + +bool WallpaperSurface::frameReady() const { + return m_frameReady.load(std::memory_order_relaxed); +} + +void WallpaperSurface::requestFrame() { + if (!m_surface || m_frameCallback) return; + + m_frameCallback = wl_surface_frame(m_surface); + + static const struct wl_callback_listener listener = { + [](void* data, struct wl_callback*, uint32_t) { + static_cast(data)->onFrameDone(); + }, + }; + + wl_callback_add_listener(m_frameCallback, &listener, this); +} + +void WallpaperSurface::onFrameDone() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + + m_frameReady.store(true, std::memory_order_relaxed); + m_service->wake(); +} + +void WallpaperSurface::endDraw() { + m_dirty.store(false, std::memory_order_relaxed); + m_frameReady.store(false, std::memory_order_relaxed); } void WallpaperSurface::zwlr_layer_surface_v1_configure( @@ -141,7 +183,9 @@ void WallpaperSurface::updateBuffer() { if (QtWayland::wp_viewport::isInitialized()) set_destination(m_size.width(), m_size.height()); + requestFrame(); m_service->queue(this); + m_service->notifyBufferSizes(); } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Wallpaper/wallpapersurface.hpp similarity index 78% rename from Plugins/ZShell/Internal/wallpapersurface.hpp rename to Plugins/ZShell/Wallpaper/wallpapersurface.hpp index d534c99..2100023 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.hpp +++ b/Plugins/ZShell/Wallpaper/wallpapersurface.hpp @@ -3,6 +3,8 @@ #include #include +#include + #include #include @@ -11,7 +13,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperService; @@ -32,7 +34,14 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } - void redraw(); + void markDirty(); + [[nodiscard]] bool needsDraw() const; + + [[nodiscard]] bool frameReady() const; + + void requestFrame(); + + void endDraw(); private: void zwlr_layer_surface_v1_configure( @@ -42,6 +51,8 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, void updateBuffer(); + void onFrameDone(); + WallpaperService* m_service; QString m_screen; wl_surface* m_surface; @@ -53,6 +64,10 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, qreal m_scale = 1.0; bool configured = false; + + std::atomic m_dirty{false}; + std::atomic m_frameReady{true}; + struct wl_callback* m_frameCallback = nullptr; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index f0c8e72..5c1fc01 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -1,10 +1,13 @@ import contextlib +import io import json import os import re import shutil +import sqlite3 import subprocess import sys +import tempfile from pathlib import Path from typing import Any @@ -31,6 +34,47 @@ from zshell.utils.schemepalettes import ( app = typer.Typer() +VIDEO_EXTENSIONS = { + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".wmv", + ".flv", + ".m4v", + ".mpg", + ".mpeg", + ".3gp", + ".ogv", + ".ts", + ".vob", +} + + +def scheme_thumb_from_db(db_path: str, wallpaper_path: str) -> bytes | None: + if not os.path.exists(db_path): + return None + + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return None + + try: + row = conn.execute( + "SELECT scheme_thumb FROM wallpapers WHERE path = ?", + (wallpaper_path,), + ).fetchone() + except sqlite3.Error: + return None + finally: + conn.close() + + if row and row[0]: + return bytes(row[0]) + return None + def _complete_scheme_name(incomplete): schemes = [ @@ -163,6 +207,7 @@ def generate( OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails") + DB_PATH = Path(HOME + "/.local/state/zshell/wallpapers.sqlite") WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json") TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates") @@ -351,6 +396,109 @@ def generate( safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) return thumb_dir / f"{safe_key}_thumbnail.jpg" + def is_video(path: Path) -> bool: + return path.suffix.lower() in VIDEO_EXTENSIONS + + def video_duration(path: Path) -> float: + try: + out = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + str(path), + ], + capture_output=True, + text=True, + check=False, + ) + return float(out.stdout.strip()) + except (ValueError, OSError): + return 0.0 + + def video_frame_chroma(path: Path) -> tuple[float, float]: + """Return (mean chroma, mean luminance) of a frame. + + Mean chroma (mean of max-min across RGB channels) is ~0 for black, + white and grey frames, and rises with saturation - so it identifies a + "good", colorful frame and rejects black or fade frames. + """ + with Image.open(path) as im: + im = im.convert("RGB") + pixels = list(im.getdata()) + + chroma = 0 + luma = 0 + for r, g, b in pixels: + chroma += max(r, g, b) - min(r, g, b) + luma += r + g + b + + n = max(1, len(pixels)) + return chroma / n, luma / (3 * n) + + def generate_video_thumbnail( + video_path: Path, cache_path: Path, size=(128, 128) + ) -> None: + w, h = size + duration = video_duration(video_path) + + samples = 8 if duration <= 0 else min(10, max(4, int(duration))) + + tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-")) + try: + best: Path | None = None + best_key: tuple[float, float] = (-1.0, -1.0) + + for i in range(samples): + frac = (i + 0.5) / samples if duration > 0 else 0.0 + seek = f"{frac * duration:.3f}" if duration > 0 else "0" + frame = tmp_dir / f"frame_{i}.jpg" + + subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + str(video_path), + "-frames:v", + "1", + "-vf", + f"scale={w}:{h}", + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + str(frame), + ], + check=False, + ) + + if not frame.exists(): + continue + + key = video_frame_chroma(frame) + if key > best_key: + best_key = key + best = frame + + if best is None: + raise RuntimeError( + f"Could not extract a frame from {video_path}" + ) + + shutil.copy(best, cache_path) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + def generate_thumbnail( image_path: Path, thumb_dir: Path, size=(128, 128) ) -> Path: @@ -360,6 +508,10 @@ def generate( if cache_path.exists(): return cache_path + if is_video(image_path): + generate_video_thumbnail(image_path, cache_path, size) + return cache_path + image = Image.open(image_path) image.draft("RGB", size) image = image.convert("RGB") @@ -683,13 +835,20 @@ def generate( seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) else: image_path = image_path or Path(WALL_PATH) - thumb_path = generate_thumbnail(image_path, THUMB_DIR) - seed = seed_from_image(thumb_path) + + blob = scheme_thumb_from_db(str(DB_PATH), str(image_path)) + if blob is not None: + open_thumb = lambda: io.BytesIO(blob) + else: + thumb_path = generate_thumbnail(image_path, THUMB_DIR) + open_thumb = lambda: thumb_path + + seed = seed_from_image(open_thumb()) name = "dynamic" flavor = "default" if smart: - effective_mode = smart_mode(thumb_path) + effective_mode = smart_mode(open_thumb()) elif mode is not None: effective_mode = mode else: diff --git a/cli/src/zshell/subcommands/shell.py b/cli/src/zshell/subcommands/shell.py index 30bd175..70ca638 100644 --- a/cli/src/zshell/subcommands/shell.py +++ b/cli/src/zshell/subcommands/shell.py @@ -14,8 +14,10 @@ qt_logging_rules = ( "qt.qpa.wayland.*=false;" "scene=false;" "quickshell.service.sni.watcher=false;" + "quickshell.dbus.*=false;" "default.warning=false;" - "default.info=false" + "default.info=false;" + "qt.sql.qsqldatabase" )