This commit is contained in:
Domipoke
2026-06-25 19:58:23 +02:00
parent 372c622025
commit 955801c666
147 changed files with 18829 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import qs.Commons
import qs.Widgets
import QtQuick
import QtMultimedia
import Qt5Compat.GraphicalEffects
Item {
id: cardContent
required property int animationDuration
required property real centerWidth
required property string filePath
required property bool isCenter
required property bool isVideo
required property real radius
required property string thumbnailPath
anchors.fill: parent
clip: true
Item {
id: imgComposite
height: cardContent.height
visible: false
width: cardContent.centerWidth
x: (cardContent.width - cardContent.centerWidth) / 2
Image {
id: img
anchors.fill: parent
// asynchronous: true // TODO: flickering when going backwards
cache: true
fillMode: Image.PreserveAspectCrop
smooth: true
source: cardContent.thumbnailPath ? "file://" + cardContent.thumbnailPath : ""
sourceSize.height: parent.height
sourceSize.width: cardContent.centerWidth
}
}
Rectangle {
id: border
anchors.fill: parent
border.color: isCenter ? Color.mOutline : Qt.alpha(Color.mOutlineVariant, 0.5)
border.width: Style.borderS
color: "transparent"
opacity: 0.75
radius: cardContent.radius
z: 20
}
Rectangle {
id: mask
anchors.fill: parent
radius: cardContent.radius
visible: false
}
OpacityMask {
anchors.fill: parent
maskSource: mask
source: ShaderEffectSource {
sourceItem: imgComposite
sourceRect: Qt.rect(-imgComposite.x, 0, cardContent.width, cardContent.height)
}
}
Loader {
id: videoLoader
property bool shouldLoad: false
property string videoPath: cardContent.isCenter && cardContent.isVideo ? cardContent.filePath : ""
active: shouldLoad && videoPath !== ""
anchors.fill: parent
z: 5
sourceComponent: Component {
Item {
id: videoContainer
anchors.fill: parent
layer.enabled: true
opacity: 0
layer.effect: OpacityMask {
maskSource: Rectangle {
height: videoContainer.height
radius: cardContent.radius
width: videoContainer.width
}
}
MediaPlayer {
id: mediaPlayer
loops: MediaPlayer.Infinite
source: "file://" + videoLoader.videoPath
videoOutput: videoOutput
audioOutput: AudioOutput {
volume: 0
}
Component.onCompleted: play()
onPlayingChanged: function () {
if (mediaPlayer.playing)
videoFadeIn.start();
}
}
VideoOutput {
id: videoOutput
anchors.fill: parent
fillMode: VideoOutput.PreserveAspectCrop
}
NumberAnimation {
id: videoFadeIn
duration: cardContent.animationDuration
easing.type: Easing.OutCubic
from: 0
property: "opacity"
target: videoContainer
to: 1
}
}
}
onVideoPathChanged: {
shouldLoad = false;
if (videoPath !== "")
videoDelayTimer.restart();
else
videoDelayTimer.stop();
}
Timer {
id: videoDelayTimer
interval: cardContent.animationDuration
onTriggered: videoLoader.shouldLoad = true
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import qs.Commons
import qs.Widgets
import QtQuick
Item {
id: cardDeck
required property int animationDuration
property real animationIndex: 0
required property int cardRadius
required property int cardSpacing
required property int cardStripWidth
required property int cardsShown
property real centerWidth: parent.width * centerWidthRatio
required property real centerWidthRatio
property real centerX: width / 2 - centerWidth / 2
property int currentIndex: 0
required property int filteredCount
required property var filteredModel
property int halfVisible: Math.floor(visibleCount / 2)
property real runningIndex: 0
required property real shearFactor
property int sideCount: Math.floor(cardsShown / 2) - 1
property int visibleCount: cardsShown
signal applyRequested(int index)
function navigateTo(idx) {
var maxLead = Math.max(1, halfVisible - 1);
if (Math.abs(runningIndex - animationIndex) >= maxLead)
return;
var newIdx = wrappedIndex(idx);
var diff = 0;
if (filteredCount > 0) {
diff = newIdx - currentIndex;
var half = filteredCount / 2;
if (diff > half)
diff -= filteredCount;
else if (diff < -half)
diff += filteredCount;
}
runningIndex += diff;
animationIndex = runningIndex;
currentIndex = newIdx;
}
function randomJump() {
var rnd = Math.floor(Math.random() * filteredCount);
if (rnd === currentIndex)
rnd = (rnd + 1) % filteredCount;
navigateTo(rnd);
}
function slotToWidth(slot) {
var t = Math.min(Math.abs(slot), 1);
return centerWidth + (cardStripWidth - centerWidth) * t;
}
function slotToX(slot) {
var rightEdge = centerX + centerWidth + cardSpacing;
var leftEdge = centerX - cardSpacing - cardStripWidth;
if (slot >= 0 && slot <= 1)
return centerX + (rightEdge - centerX) * slot;
if (slot > 1)
return rightEdge + (slot - 1) * (cardStripWidth + cardSpacing);
if (slot >= -1 && slot < 0)
return centerX + (leftEdge - centerX) * (-slot);
if (slot < -1)
return leftEdge + (slot + 1) * (cardStripWidth + cardSpacing);
return 0;
}
function wrappedIndex(idx) {
return ((idx % filteredCount) + filteredCount) % filteredCount;
}
function jumpTo(idx) {
animBehavior.enabled = false;
runningIndex = idx;
animationIndex = idx;
currentIndex = idx;
reenableTimer.restart();
}
Timer {
id: reenableTimer
interval: 0
onTriggered: animBehavior.enabled = true
}
width: (cardsShown - 3) * cardStripWidth + (cardsShown - 3) * cardSpacing + centerWidth
Behavior on animationIndex {
id: animBehavior
NumberAnimation {
duration: cardDeck.animationDuration
easing.type: Easing.OutCubic
}
}
transform: Matrix4x4 {
property real s: cardDeck.shearFactor
matrix: Qt.matrix4x4(1, s, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Repeater {
model: cardDeck.filteredCount > 0 ? cardDeck.visibleCount : 0
delegate: Rectangle {
id: card
property real fractionalSlot: offset + (cardDeck.runningIndex - cardDeck.animationIndex)
property bool isCenter: offset === 0
property int modelIndex: cardDeck.wrappedIndex(Math.round(cardDeck.runningIndex) + offset)
property int offset: index - cardDeck.halfVisible
signal applyRequested
border.color: isCenter ? Color.mOutline : Color.mSurface
border.width: isCenter ? Style.borderM : Style.borderS
color: "transparent"
height: cardDeck.height
opacity: Math.max(0, Math.min(1, cardDeck.halfVisible - Math.abs(fractionalSlot)))
radius: cardDeck.cardRadius
visible: (x + width) > 0 && x < cardDeck.width
width: cardDeck.slotToWidth(fractionalSlot)
x: cardDeck.slotToX(fractionalSlot)
y: 0
z: isCenter ? 100 : cardDeck.visibleCount - Math.abs(offset)
Card {
animationDuration: cardDeck.animationDuration
centerWidth: cardDeck.centerWidth
filePath: cardDeck.filteredModel[card.modelIndex]?.filePath ?? ""
isCenter: card.isCenter
isVideo: cardDeck.filteredModel[card.modelIndex]?.isVideo ?? false
radius: cardDeck.cardRadius
thumbnailPath: cardDeck.filteredModel[card.modelIndex]?.thumbnail ?? ""
}
MouseArea {
anchors.fill: parent
cursorShape: card.isCenter ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (card.isCenter)
cardDeck.applyRequested(card.modelIndex);
}
//
onWheel: function (wheel) {
if (wheel.angleDelta.y > 0)
cardDeck.navigateTo(cardDeck.currentIndex - 1);
else if (wheel.angleDelta.y < 0)
cardDeck.navigateTo(cardDeck.currentIndex + 1);
}
}
}
}
}
@@ -0,0 +1,101 @@
import qs.Commons
import qs.Widgets
import QtQuick
Rectangle {
id: loadingBar
property bool loading: pending > 0
required property int pending
property real progress: total > 0 ? 1 - (pending / total) : 0
required property int total
color: Qt.alpha(Color.mSurface, 0.9)
height: upperRow.height + Style.margin2S + progressBar.height
radius: Style.radiusXS
visible: loading
width: upperRow.width + Style.margin2L
Behavior on opacity {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
Row {
id: upperRow
anchors.centerIn: parent
anchors.verticalCenterOffset: -progressBar.height / 2
Item {
anchors.verticalCenter: parent.verticalCenter
Repeater {
model: 3
Rectangle {
property real angle: index * (2 * Math.PI / 3)
color: Color.mPrimary
height: Style.marginXS
radius: Style.marginXXS
width: Style.marginXS
x: Style.marginS + Style.marginS * Math.cos(angle + spinAnimation.value)
y: Style.marginS + Style.marginS * Math.sin(angle + spinAnimation.value)
NumberAnimation on opacity {
duration: 600
from: 0.3
loops: Animation.Infinite
running: loadingBar.loading
to: 1
}
}
}
NumberAnimation {
id: spinAnimation
property real value: 0
duration: 1200
from: 0
loops: Animation.Infinite
property: "value"
running: loadingBar.loading
target: spinAnimation
to: 2 * Math.PI
}
}
NText {
text: `${root.pluginApi?.tr("widget.generate-thumbs-message")} ${String(Math.max(loadingBar.total - loadingBar.pending, 0)).padStart(String(loadingBar.total).length, " ")} / ${loadingBar.total}`
}
}
Rectangle {
id: progressBar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
color: Qt.alpha(Color.mOnSurface, 0.1)
height: Style.marginS
radius: loadingBar.radius
Rectangle {
color: Color.mPrimary
height: parent.height
radius: parent.radius
width: parent.width * loadingBar.progress
Behavior on width {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.Commons
import qs.Widgets
Row {
id: hint
required property string keys
required property string label
spacing: Style.marginXS
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginXXS
Repeater {
model: hint.keys.split(" / ")
Row {
required property int index
required property var modelData
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginXXS
// Separator between keys
NText {
anchors.verticalCenter: parent.verticalCenter
color: Qt.alpha(Color.mOnSurface, 0.3)
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.key-separator")
visible: index > 0
}
// Keycap
Rectangle {
anchors.verticalCenter: parent.verticalCenter
border.color: Qt.alpha(Color.mOnSurface, 0.2)
border.width: Style.borderS
color: Qt.alpha(Color.mOnSurface, 0.08)
height: Style.marginL + 2
radius: Style.radiusXS
width: Math.max(Style.marginL + 2, keycapText.width + Style.marginS)
Rectangle {
anchors.bottomMargin: 2
anchors.fill: parent
border.color: Qt.alpha(Color.mOnSurface, 0.15)
border.width: Style.borderS
color: Qt.alpha(Color.mOnSurface, 0.05)
radius: Style.radiusXS
NText {
id: keycapText
anchors.centerIn: parent
color: Qt.alpha(Color.mOnSurface, 0.6)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: modelData.trim()
}
}
}
}
}
}
NText {
anchors.verticalCenter: parent.verticalCenter
color: Qt.alpha(Color.mOnSurface, 0.4)
font.pointSize: Style.fontSizeXXS
text: hint.label
}
}
+203
View File
@@ -0,0 +1,203 @@
import QtQuick
import qs.Commons
import qs.Widgets
Rectangle {
id: sideBar
property bool expanded: false
property bool hideHelp: true
color: Qt.alpha(Color.mSurface, 0.9)
radius: Style.radiusS
width: expanded ? shortcutColumn.width + Style.margin2L : !hideHelp ? collapsedColumn.width + Style.margin2L : 0
height: expanded ? shortcutColumn.height + Style.margin2L : !hideHelp ? collapsedColumn.height + Style.margin2L : 0
Behavior on width {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
Behavior on height {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
clip: true
Column {
id: collapsedColumn
anchors.centerIn: parent
spacing: Style.marginS
visible: !sideBar.expanded && !sideBar.hideHelp
ShortcutHint {
keys: "?"
label: root.pluginApi?.tr("shortcuts.label.help-title")
}
}
Column {
id: shortcutColumn
anchors.centerIn: parent
spacing: Style.marginXS
visible: sideBar.expanded
// Navigation
NText {
color: Qt.alpha(Color.mOnSurface, 0.35)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.header.navigation")
}
ShortcutHint {
keys: "J / K"
label: root.pluginApi?.tr("shortcuts.label.navigate")
}
ShortcutHint {
keys: "H / L"
label: root.pluginApi?.tr("shortcuts.label.jump")
}
ShortcutHint {
keys: "R"
label: root.pluginApi?.tr("shortcuts.label.shuffle")
}
// Separator
Rectangle {
color: Qt.alpha(Color.mOnSurface, 0.1)
height: 1
width: shortcutColumn.width
}
// Actions
NText {
color: Qt.alpha(Color.mOnSurface, 0.35)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.header.actions")
}
ShortcutHint {
keys: "ENTER"
label: root.pluginApi?.tr("shortcuts.label.apply-quit")
}
ShortcutHint {
keys: "SPACE"
label: root.pluginApi?.tr("shortcuts.label.apply")
}
ShortcutHint {
keys: "ESC / Q"
label: root.pluginApi?.tr("shortcuts.label.quit")
}
// Separator
Rectangle {
color: Qt.alpha(Color.mOnSurface, 0.1)
height: 1
width: shortcutColumn.width
}
// Filters
NText {
color: Qt.alpha(Color.mOnSurface, 0.35)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.header.filters")
}
ShortcutHint {
keys: "A"
label: root.pluginApi?.tr("shortcuts.label.filter-all")
}
ShortcutHint {
keys: "I"
label: root.pluginApi?.tr("shortcuts.label.filter-images")
}
ShortcutHint {
keys: "V"
label: root.pluginApi?.tr("shortcuts.label.filter-videos")
}
ShortcutHint {
keys: "F"
label: root.pluginApi?.tr("shortcuts.label.filter-colors")
}
// Separator
Rectangle {
color: Qt.alpha(Color.mOnSurface, 0.1)
height: 1
width: shortcutColumn.width
}
// View
NText {
color: Qt.alpha(Color.mOnSurface, 0.35)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.header.view")
}
ShortcutHint {
keys: "T"
label: root.pluginApi?.tr("shortcuts.label.top-bar")
}
ShortcutHint {
keys: "P"
label: root.pluginApi?.tr("shortcuts.label.live-preview")
}
// Separator
Rectangle {
color: Qt.alpha(Color.mOnSurface, 0.1)
height: 1
width: shortcutColumn.width
}
// Layout
NText {
color: Qt.alpha(Color.mOnSurface, 0.35)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: root.pluginApi?.tr("shortcuts.header.layout")
}
ShortcutHint {
keys: "SHIFT + H / L"
label: root.pluginApi?.tr("shortcuts.label.center-height")
}
ShortcutHint {
keys: "SHIFT + J / K"
label: root.pluginApi?.tr("shortcuts.label.center-width")
}
ShortcutHint {
keys: "SHIFT + N / P"
label: root.pluginApi?.tr("shortcuts.label.cards-shown")
}
ShortcutHint {
keys: "CTRL + J / K"
label: root.pluginApi?.tr("shortcuts.label.cards-spacing")
}
ShortcutHint {
keys: "CTRL + H / L"
label: root.pluginApi?.tr("shortcuts.label.cards-width")
}
// Separator
Rectangle {
color: Qt.alpha(Color.mOnSurface, 0.1)
height: 1
width: shortcutColumn.width
}
ShortcutHint {
keys: "CTRL + S"
label: root.pluginApi?.tr("shortcuts.label.save")
}
ShortcutHint {
keys: "?"
label: root.pluginApi?.tr("shortcuts.label.hide")
}
}
}
@@ -0,0 +1,275 @@
import "Utils.js" as Utils
import QtQuick
import Quickshell.Io
import Qt.labs.folderlistmodel
Item {
id: service
property int maxThumbnailJobs: 4
property var pendingJobs: []
property int activeJobs: 0
required property string cacheDir
required property var imageFilter
required property var videoFilter
required property string wallpaperDir
property var colorOrder: ["Red", "Orange", "Green", "Teal", "Blue", "Purple", "Pink", "Monochrome"]
property var colorOrderColors: ["#FF4500", "#FFA500", "#32CD32", "#2EC4B6", "#1E90FF", "#8A2BE2", "#FF69B4", "#A9A9A9"]
property int fileCount: files.length
property var files: []
property bool loading: true
property int pendingProcesses: 0
property int thumbnailRevision: 0
signal ready
function toLocalPath(urlStr) {
return String(urlStr).replace(/^file:\/\//, "");
}
FolderListModel {
id: thumbnailModel
folder: service.wallpaperDir ? Qt.resolvedUrl("file://" + service.wallpaperDir) : ""
nameFilters: Utils.nameFilters(service.imageFilter, service.videoFilter)
showDirs: false
sortField: FolderListModel.Name
onStatusChanged: {
if (status === FolderListModel.Ready) {
service.createThumbnails();
}
}
}
function createThumbnails() {
var proc = processComponent.createObject(null, {
command: ["mkdir", "-p", cacheDir]
});
proc.running = true;
var items = [];
var jobs = [];
for (var i = 0; i < thumbnailModel.count; i++) {
(function (idx) {
var filePath = toLocalPath(thumbnailModel.get(idx, "filePath"));
var fileName = thumbnailModel.get(idx, "fileName");
var isVid = Utils.isVideo(fileName, service.videoFilter);
var thumbName = isVid ? fileName + ".jpg" : fileName;
var thumbnailPath = cacheDir + "/" + thumbName;
var thumbnailCmd = isVid ? videoToThumbnailCmd(filePath, thumbnailPath) : imageToThumbnailCmd(filePath, thumbnailPath);
var hexCmd = thumbnailHexValueCmd(thumbnailPath);
const script = `
[ -f "${thumbnailPath}"* ] && exit 0
${thumbnailCmd}
mv "${thumbnailPath}" "${thumbnailPath}__x$(${hexCmd})"
`;
jobs.push({
script: script,
thumbnailPath: thumbnailPath
});
items.push({});
})(i);
}
pendingJobs = jobs;
activeJobs = 0;
processNextJob();
if (thumbnailModel.count === 0) {
service.loading = false;
}
files = items;
}
function processNextJob() {
while (activeJobs < maxThumbnailJobs && pendingJobs.length > 0) {
var job = pendingJobs.shift();
activeJobs++;
service.pendingProcesses++;
createSingleThumbnail(job, function() {
activeJobs--;
processNextJob();
});
}
}
function createSingleThumbnail(job, onFinished) {
var proc = processComponent.createObject(null, {
command: ["bash", "-c", job.script]
});
proc.exited.connect(function () {
service.pendingProcesses--;
service.thumbnailRevision++;
if (service.pendingProcesses === 0) {
filesModel.running = true;
}
proc.destroy();
if (onFinished) onFinished();
});
proc.running = true;
}
function imageToThumbnailCmd(filePath, thumbnailPath) {
return `magick "${filePath}" \
-resize x500 \
-quality 95 \
"${thumbnailPath}"
`;
}
function videoToThumbnailCmd(filePath, thumbnailPath) {
return `ffmpeg -y -i \
"${filePath}" \
-vf "select=eq(n\\,0),scale=-1:1080" \
-frames:v 1 \
-q:v 2 \
"${thumbnailPath}" </dev/null 2>/dev/null`;
}
function thumbnailHexValueCmd(thumbnailPath) {
return `magick "${thumbnailPath}" \
-resize "1x1^" \
-gravity center \
-extent 1x1 \
-depth 8 \
-format "%[hex:p{0,0}]" info:- 2>/dev/null \
| grep -oE '[0-9A-Fa-f]{6}' \
| head -n 1`;
}
FolderListModel {
id: filesModel
nameFilters: ["*__x*"]
showDirs: false
sortField: FolderListModel.Name
property bool running: false
onRunningChanged: {
if (running)
folder = Qt.resolvedUrl("file://" + service.cacheDir);
}
onStatusChanged: {
if (status === FolderListModel.Ready && running) {
service.buildFileList();
running = false;
}
}
}
function buildFileList() {
// Ground truth needed to avoid showing thumbnails for files
// that are no longer in the wallpaper directory.
var existingFiles = new Set();
for (let j = 0; j < thumbnailModel.count; j++) {
existingFiles.add(thumbnailModel.get(j, "fileName"));
}
var items = [];
for (let i = 0; i < filesModel.count; i++) {
const filePath = toLocalPath(filesModel.get(i, "filePath"));
const fileName = filesModel.get(i, "fileName");
const idx = fileName.lastIndexOf("__x");
if (idx === -1)
continue;
const thumbBase = fileName.substring(0, idx);
const hexColor = fileName.substring(idx + 3);
// Video thumbnails are named <original>.<vidext>.jpg__x<hex>
// Strip trailing .jpg to recover the original video filename.
var isVid = thumbBase.toLowerCase().endsWith(".jpg") && Utils.isVideo(thumbBase.substring(0, thumbBase.lastIndexOf(".")), service.videoFilter);
var wallpaperName = isVid ? thumbBase.substring(0, thumbBase.lastIndexOf(".")) : thumbBase;
if (!existingFiles.has(wallpaperName)) {
continue;
}
items.push({
fileName: wallpaperName,
filePath: wallpaperDir + "/" + wallpaperName,
thumbnail: filePath,
hexCode: hexColor,
filterColor: getFilterColor(hexColor),
isVideo: isVid
});
}
items.sort((a, b) => colorOrder.indexOf(a.filterColor) - colorOrder.indexOf(b.filterColor));
files = items;
service.loading = false;
service.ready();
}
function getFilterColor(hexColor) {
if (!hexColor)
return "Monochrome";
const cleaned = String(hexColor).trim().replace(/x/g, '').substring(0, 6);
if (cleaned.length !== 6)
return "Monochrome";
const r = parseInt(cleaned.substring(0, 2), 16) / 255;
const g = parseInt(cleaned.substring(2, 4), 16) / 255;
const b = parseInt(cleaned.substring(4, 6), 16) / 255;
if ([r, g, b].some(isNaN))
return "Monochrome";
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
let s = max === 0 ? 0 : d / max;
let v = max;
if (d !== 0) {
if (max === r)
h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g)
h = (b - r) / d + 2;
else
h = (r - g) / d + 4;
h = (h / 6) * 360;
}
if (s < 0.15 || v < 0.10)
return "Monochrome";
if (h >= 345 || h < 15)
return "Red";
if (h < 50)
return "Orange";
if (h < 160)
return "Green";
if (h < 200)
return "Teal";
if (h < 260)
return "Blue";
if (h < 315)
return "Purple";
if (h < 345)
return "Pink";
return "Monochrome";
}
Component {
id: processComponent
Process {}
}
}
+418
View File
@@ -0,0 +1,418 @@
import qs.Commons
import qs.Widgets
import QtQuick
Rectangle {
id: topBar
required property int animationDuration
required property var availableColors
required property var colorOrder
required property var colorOrderColors
required property string currentCardColor
required property int currentIndex
property real entryOffset: parent.width / 2
required property int filteredCount
required property bool livePreview
required property var pluginApi
required property string selectedColorFilter
required property string selectedFilter
required property real shearFactor
signal colorFilterSelected(string key)
signal filterSelected(string key)
signal livePreviewToggled
signal shuffleRequested
function flashShuffle() {
shuffleBtn.flash();
}
color: Color.mSurface
Behavior on entryOffset {
NumberAnimation {
duration: topBar.animationDuration
easing.overshoot: 1.0
easing.type: Easing.OutBack
}
}
transform: Matrix4x4 {
property real s: topBar.shearFactor
matrix: Qt.matrix4x4(1, s, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Component.onCompleted: entryOffset = 0
// ── Inline Components ──
component TButton: Rectangle {
id: btn
property color accentColor: active ? Color.mOnSurface : Color.mOnSurfaceVariant
property bool active: false
property string hotkey: ""
property string icon: ""
property string label: ""
property bool pulsing: false
signal clicked
border.color: active ? Qt.alpha(accentColor, Style.opacityMedium) : Qt.alpha(Color.mOutline, 0.3)
border.width: Style.borderS
color: active ? Qt.alpha(accentColor, 0.15) : Qt.alpha(Color.mOnSurface, 0.06)
height: Style.margin2L
radius: Style.radiusM
width: btnRow.width + Style.margin2M
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Row {
id: btnRow
anchors.centerIn: parent
spacing: Style.marginS
PulsingDot {
anchors.verticalCenter: parent.verticalCenter
pulsing: btn.pulsing
visible: btn.pulsing
}
NIcon {
color: btn.accentColor
icon: btn.icon
visible: btn.icon !== ""
}
NText {
anchors.verticalCenter: parent.verticalCenter
color: btn.accentColor
font.pointSize: Style.fontSizeXS
text: btn.label
visible: btn.label !== ""
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
border.color: Qt.alpha(btn.accentColor, 0.35)
border.width: Style.borderM
color: Qt.alpha(btn.accentColor, 0.12)
height: Style.marginL + 2
opacity: 0.25
radius: Style.radiusXS
visible: btn.hotkey !== ""
width: Style.marginL + 5
Rectangle {
anchors.bottomMargin: 2
anchors.fill: parent
border.color: Qt.alpha(btn.accentColor, 0.25)
border.width: Style.borderS
color: Qt.alpha(btn.accentColor, 0.1)
radius: Style.radiusXS
NText {
anchors.centerIn: parent
color: Qt.alpha(btn.accentColor, 0.7)
font.bold: true
font.pointSize: Style.fontSizeXXS
text: btn.hotkey
}
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: btn.clicked()
}
}
component CButton: Rectangle {
id: colorBtn
property bool active: false
property bool available: true
property bool current: false
required property color faceColor
signal clicked
border.color: active ? Color.mOnSurface : Qt.alpha(Color.mOutline, 0.3)
border.width: Style.borderS
color: faceColor
height: Style.margin2L
opacity: active ? 1.0 : available ? 0.4 : 0.12
radius: Style.radiusM
width: height
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.bottomMargin: -height - 2
anchors.horizontalCenter: parent.horizontalCenter
color: colorBtn.current ? colorBtn.faceColor : "transparent"
height: 3
radius: height / 2
width: colorBtn.current ? parent.width * 0.6 : 0
Behavior on width {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
NText {
anchors.centerIn: parent
color: Color.mOnSurface
font.bold: true
font.pointSize: Style.fontSizeS
text: topBar.pluginApi?.tr("buttons.color-na")
visible: !colorBtn.available
}
MouseArea {
anchors.fill: parent
cursorShape: colorBtn.available ? Qt.PointingHandCursor : Qt.ForbiddenCursor
enabled: colorBtn.available
onClicked: colorBtn.clicked()
}
}
component SButton: Item {
id: shuffleItem
property string hotkey: ""
property string icon: ""
property string label: ""
signal clicked
function flash() {
flashAnim.restart();
}
height: innerBtn.height
width: innerBtn.width
TButton {
id: innerBtn
hotkey: shuffleItem.hotkey
icon: shuffleItem.icon
label: shuffleItem.label
onClicked: {
shuffleItem.clicked();
shuffleItem.flash();
}
}
Rectangle {
id: flashOverlay
anchors.fill: innerBtn
color: Color.mPrimary
opacity: 0
radius: innerBtn.radius
}
SequentialAnimation {
id: flashAnim
NumberAnimation {
duration: 80
easing.type: Easing.OutCubic
property: "opacity"
target: flashOverlay
to: 0.3
}
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
property: "opacity"
target: flashOverlay
to: 0
}
}
}
component PulsingDot: Rectangle {
id: root
property color dotColor: Color.mError
property bool pulsing: false
color: dotColor
height: Style.marginS
radius: Style.marginXXXS
width: Style.marginS
SequentialAnimation {
id: pulseAnimation
loops: Animation.Infinite
running: root.pulsing
onRunningChanged: {
if (!running)
root.opacity = 1.0;
}
NumberAnimation {
duration: 800
easing.type: Easing.InOutSine
from: 1.0
property: "opacity"
target: root
to: 0.3
}
NumberAnimation {
duration: 800
easing.type: Easing.InOutSine
from: 0.3
property: "opacity"
target: root
to: 1.0
}
}
}
// ── Left ──
NText {
anchors.left: parent.left
anchors.leftMargin: Style.marginL
anchors.verticalCenter: parent.verticalCenter
text: (topBar.currentIndex + 1) + " / " + topBar.filteredCount
}
// ── Center ──
Row {
anchors.centerIn: parent
spacing: Style.marginXS
Repeater {
model: [
{
key: "all",
label: topBar.pluginApi?.tr("buttons.all"),
icon: "wallpaper",
hotkey: "A"
},
{
key: "images",
label: topBar.pluginApi?.tr("buttons.images"),
icon: "image",
hotkey: "I"
},
{
key: "videos",
label: topBar.pluginApi?.tr("buttons.videos"),
icon: "video",
hotkey: "V"
}
]
TButton {
required property var modelData
active: topBar.selectedFilter === modelData.key
hotkey: modelData.hotkey
icon: modelData.icon
label: modelData.label || ""
onClicked: topBar.filterSelected(modelData.key)
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: Qt.alpha(Color.mOnSurface, 0.15)
height: parent.height * 0.5
width: 1
}
Repeater {
model: topBar.colorOrder
CButton {
required property int index
required property string modelData
active: topBar.selectedColorFilter === modelData
anchors.verticalCenter: parent.verticalCenter
available: topBar.availableColors.indexOf(modelData) !== -1
current: topBar.selectedColorFilter === "" && topBar.currentCardColor === modelData
faceColor: topBar.colorOrderColors[index]
onClicked: {
if (active)
topBar.colorFilterSelected("");
else
topBar.colorFilterSelected(modelData);
}
}
}
}
// ── Right ──
Row {
anchors.right: parent.right
anchors.rightMargin: Style.marginL
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginXS
SButton {
id: shuffleBtn
hotkey: "R"
icon: "arrows-random"
label: topBar.pluginApi?.tr("buttons.shuffle")
onClicked: topBar.shuffleRequested()
}
TButton {
accentColor: topBar.livePreview ? Color.mTertiary : Color.mOnSurfaceVariant
active: topBar.livePreview
hotkey: "P"
label: topBar.pluginApi?.tr("buttons.live-preview")
pulsing: topBar.livePreview
onClicked: topBar.livePreviewToggled()
}
}
}
+37
View File
@@ -0,0 +1,37 @@
function getExtension(fileName) {
return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
}
function isVideo(fileName, filterVideos) {
return filterVideos.indexOf(getExtension(fileName)) !== -1;
}
function isImage(fileName, filterImages) {
return filterImages.indexOf(getExtension(fileName)) !== -1;
}
function nameFilters(filterImages, filterVideos) {
return (filterImages || []).concat(filterVideos || []).map((ext) => "*." + ext);
}
var mpvpaperKill = "killall -9 mpvpaper 2>/dev/null || true";
var mpvpaperOptions = [
"loop",
"--no-audio",
"--hwdec=auto",
"--profile=high-quality",
"--video-sync=display-resample",
"--interpolation",
"--tscale=oversample"
].join(" ");
function mpvpaperRun(filePath) {
return "mpvpaper -o '" + mpvpaperOptions + "' '*' \"" + filePath + "\" >/dev/null 2>&1 & disown";
}
function wallpaperCommand(entry) {
if (entry.isVideo)
return mpvpaperKill + "; " + mpvpaperRun(entry.filePath);
return mpvpaperKill;
}