diff --git a/modules/background/Background.qml b/modules/background/Background.qml index fbacfabb8..64bb6d8dd 100644 --- a/modules/background/Background.qml +++ b/modules/background/Background.qml @@ -19,7 +19,6 @@ Loader { id: win required property ShellScreen modelData - screen: modelData name: "background" WlrLayershell.exclusionMode: ExclusionMode.Ignore diff --git a/modules/background/ImageWallpaper.qml b/modules/background/ImageWallpaper.qml new file mode 100644 index 000000000..6842ab044 --- /dev/null +++ b/modules/background/ImageWallpaper.qml @@ -0,0 +1,65 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.images +import qs.components.filedialog +import qs.services +import qs.config +import qs.utils +import QtQuick + +Item { + id: root + anchors.fill: parent + + property bool isCurrent: false + + signal ready + + function update(explicitSource) { + explicitSource = explicitSource.toString(); + const target = explicitSource; + + if (img.path === target && img.status === Image.Ready) { + console.log("Same path, emitting ready manually for:", target); + Qt.callLater(() => root.ready()); + return; + } + + img.path = target; + + img.onStatusChanged.connect(function handler() { + if (img.status === Image.Ready) { + Qt.callLater(() => root.ready()); + console.log("Called ready for: ", target); + + img.onStatusChanged.disconnect(handler); + } + }); + } + + CachingImage { + id: img + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + opacity: root.isCurrent ? 1 : 0 + scale: Wallpapers.showPreview ? 1 : 0.8 + states: State { + name: "visible" + when: root.isCurrent + PropertyChanges { + target: img + opacity: 1 + scale: 1 + } + } + + transitions: Transition { + Anim { + target: img + properties: "opacity,scale" + } + } + } +} diff --git a/modules/background/VideoWallpaper.qml b/modules/background/VideoWallpaper.qml new file mode 100644 index 000000000..e06d00ff3 --- /dev/null +++ b/modules/background/VideoWallpaper.qml @@ -0,0 +1,104 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.images +import qs.components.filedialog +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtMultimedia + +Item { + id: root + anchors.fill: parent + + property string source: "" + property bool isCurrent: false + + signal ready + signal failed + + property string activePath: "" + + function update(path) { + path = path.toString(); + if (!path || path.trim() === "") + return; + + root.source = path; + player.source = path; + + console.log("update ⚠️ Switching video to:", path); + + player.onMediaStatusChanged.connect(function handler() { + if (player.mediaStatus === MediaPlayer.BufferedMedia || player.mediaStatus === MediaPlayer.LoadedMedia) { + if (root.source === path) { + console.log("Media loaded LoadedMedia, emitting ready:", path); + Qt.callLater(() => root.ready()); + player.onMediaStatusChanged.disconnect(handler); + } + } + }); + + player.play(); + } + + MediaPlayer { + id: player + + autoPlay: false + loops: MediaPlayer.Infinite + + videoOutput: video + audioOutput: AudioOutput {} + + onErrorOccurred: root.failed() + + function tryPlayVideo() { + if (root.isCurrent) { + if (source && mediaStatus !== MediaPlayer.PlayingState) { + console.log("Starting video:", source); + play(); + } + } else { + if (mediaStatus !== MediaPlayer.NoMedia) { + console.log("Stopping video:", source); + stop(); + // source = ""; + } + } + } + } + + VideoOutput { + id: video + anchors.fill: parent + opacity: root.isCurrent ? 1 : 0 + scale: Wallpapers.showPreview ? 1 : 0.8 + } + + states: State { + name: "visible" + when: root.isCurrent + PropertyChanges { + target: video + opacity: 1 + scale: 1 + } + } + + transitions: Transition { + Anim { + target: video + properties: "opacity,scale" + } + } + + Connections { + target: root + function onIsCurrentChanged() { + player.tryPlayVideo(); + } + } +} diff --git a/modules/background/Wallpaper.qml b/modules/background/Wallpaper.qml index 233dacb72..f00bcaabb 100644 --- a/modules/background/Wallpaper.qml +++ b/modules/background/Wallpaper.qml @@ -10,139 +10,122 @@ import QtQuick Item { id: root + anchors.fill: parent property string source: Wallpapers.current - property Image current: one + property bool initialized: false + property int loadersReady: 0 + property int previousCurrent: 2 + property bool initStart: false + + function isVideo(path) { + path = path.toString(); + if (!path || path.trim() === "") + return false; + const videoExtensions = [".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".wmv", ".gif"]; + const lowerPath = path.toLowerCase(); + for (let i = 0; i < videoExtensions.length; i++) { + if (lowerPath.endsWith(videoExtensions[i])) + return true; + } + return false; + } - anchors.fill: parent + function waitForItem(loader, callback) { + if (loader.item !== null) { + callback(); + return; + } - onSourceChanged: { - if (!source) - current = null; - else if (current === one) - two.update(); - else - one.update(); + // Wait for next frame until item exists + Qt.callLater(function () { + waitForItem(loader, callback); + }); } - Component.onCompleted: { - if (source) - Qt.callLater(() => one.update()); + function switchWallpaper() { + if (oneLoader.item.isCurrent) + previousCurrent = 1; + else if (twoLoader.item.isCurrent) + previousCurrent = 2; + if (oneLoader.item.isCurrent) { + twoLoader.sourceComponent = isVideo(source) ? videoComponent : imageComponent; + + waitForItem(twoLoader, function () { + twoLoader.item.update(source); + twoLoader.item.ready.connect(function handler() { + oneLoader.item.isCurrent = false; + twoLoader.item.isCurrent = true; + console.log("source changed from two -> one:", oneLoader.item.isCurrent, "two:", twoLoader.item.isCurrent); + twoLoader.item.ready.disconnect(handler); + }); + }); + } else if (twoLoader.item.isCurrent) { + oneLoader.sourceComponent = isVideo(source) ? videoComponent : imageComponent; + + waitForItem(oneLoader, function () { + oneLoader.item.update(source); + oneLoader.item.ready.connect(function handler() { + twoLoader.item.isCurrent = false; + oneLoader.item.isCurrent = true; + console.log("source changed from one -> one:", oneLoader.item.isCurrent, "two:", twoLoader.item.isCurrent); + oneLoader.item.ready.disconnect(handler); + }); + }); + } } - Loader { - anchors.fill: parent + function tryInitialize(from) { + loadersReady += 1; - active: !root.source - asynchronous: true + if (loadersReady < 2) + return; - sourceComponent: StyledRect { - color: Colours.palette.m3surfaceContainer - - Row { - anchors.centerIn: parent - spacing: Appearance.spacing.large - - MaterialIcon { - text: "sentiment_stressed" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge * 5 - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.small - - StyledText { - text: qsTr("Wallpaper missing?") - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge * 2 - font.bold: true - } - - StyledRect { - implicitWidth: selectWallText.implicitWidth + Appearance.padding.large * 2 - implicitHeight: selectWallText.implicitHeight + Appearance.padding.small * 2 - - radius: Appearance.rounding.full - color: Colours.palette.m3primary - - FileDialog { - id: dialog - - title: qsTr("Select a wallpaper") - filterLabel: qsTr("Image files") - filters: Images.validImageExtensions - onAccepted: path => Wallpapers.setWallpaper(path) - } - - StateLayer { - radius: parent.radius - color: Colours.palette.m3onPrimary - - function onClicked(): void { - dialog.open(); - } - } - - StyledText { - id: selectWallText - - anchors.centerIn: parent - - text: qsTr("Set it now!") - color: Colours.palette.m3onPrimary - font.pointSize: Appearance.font.size.large - } - } - } - } + if (previousCurrent === 1) { + oneLoader.item.isCurrent = true; + twoLoader.item.isCurrent = false; + } else { + oneLoader.item.isCurrent = false; + twoLoader.item.isCurrent = true; } - } - Img { - id: one + initialized = true; + if (!initStart) { + switchWallpaper(); + initStart = true; + } } - Img { - id: two + Loader { + id: oneLoader + asynchronous: true + anchors.fill: parent + sourceComponent: isVideo(root.source) ? videoComponent : imageComponent + onLoaded: tryInitialize("oneLoader") } - component Img: CachingImage { - id: img - - function update(): void { - if (path === root.source) - root.current = this; - else - path = root.source; - } - + Loader { + id: twoLoader + asynchronous: true anchors.fill: parent + sourceComponent: isVideo(root.source) ? videoComponent : imageComponent + onLoaded: tryInitialize("twoLoader") + } - opacity: 0 - scale: Wallpapers.showPreview ? 1 : 0.8 - - onStatusChanged: { - if (status === Image.Ready) - root.current = this; + onSourceChanged: { + if (!initialized || !source) { + return; } - states: State { - name: "visible" - when: root.current === img - - PropertyChanges { - img.opacity: 1 - img.scale: 1 - } - } + switchWallpaper(); + } - transitions: Transition { - Anim { - target: img - properties: "opacity,scale" - } - } + Component { + id: imageComponent + ImageWallpaper {} + } + Component { + id: videoComponent + VideoWallpaper {} } } diff --git a/plugin/src/Caelestia/Models/filesystemmodel.cpp b/plugin/src/Caelestia/Models/filesystemmodel.cpp index e387ecd00..7dc1ded9f 100644 --- a/plugin/src/Caelestia/Models/filesystemmodel.cpp +++ b/plugin/src/Caelestia/Models/filesystemmodel.cpp @@ -6,6 +6,12 @@ namespace caelestia::models { +static bool isVideoFile(const QString& path) { + static const QStringList videoExtensions = { "mp4", "mkv", "webm", "avi", "mov", "flv", "wmv" }; + const QString ext = QFileInfo(path).suffix().toLower(); + return videoExtensions.contains(ext); +} + FileSystemEntry::FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent) : QObject(parent) , m_fileInfo(path) @@ -291,83 +297,84 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { const auto nameFilters = m_nameFilters; QSet oldPaths; - for (const auto& entry : std::as_const(m_entries)) { + for (const auto& entry : std::as_const(m_entries)) oldPaths << entry->path(); - } const auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) { const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; - std::optional iter; + QSet newPaths; - if (filter == Images) { + // Build name filters dynamically + if (filter == Images || filter == Videos || filter == ImagesAndVideos) { QStringList extraNameFilters = nameFilters; - const auto formats = QImageReader::supportedImageFormats(); - for (const auto& format : formats) { - extraNameFilters << "*." + format; - } - - QDir::Filters filters = QDir::Files; - if (showHidden) { - filters |= QDir::Hidden; - } - iter.emplace(dir, extraNameFilters, filters, flags); - } else { - QDir::Filters filters; - - if (filter == Files) { - filters = QDir::Files; - } else if (filter == Dirs) { - filters = QDir::Dirs | QDir::NoDotAndDotDot; - } else { - filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot; + if (filter == Images || filter == ImagesAndVideos) { + for (const auto& fmt : QImageReader::supportedImageFormats()) + extraNameFilters << "*." + QString(fmt); } - if (showHidden) { - filters |= QDir::Hidden; + if (filter == Videos || filter == ImagesAndVideos) { + extraNameFilters << "*.mp4" << "*.mkv" << "*.webm" + << "*.avi" << "*.mov" << "*.flv" << "*.wmv"; } - if (nameFilters.isEmpty()) { - iter.emplace(dir, filters, flags); - } else { - iter.emplace(dir, nameFilters, filters, flags); - } - } + QDir::Filters dirFilters = QDir::Files; + if (showHidden) + dirFilters |= QDir::Hidden; - QSet newPaths; - while (iter->hasNext()) { - if (promise.isCanceled()) { - return; - } + QDirIterator iter(dir, extraNameFilters, dirFilters, flags); + while (iter.hasNext()) { + if (promise.isCanceled()) + return; + const QString path = iter.next(); - QString path = iter->next(); + // Determine whether to keep file + bool isImage = QImageReader(path).canRead(); + bool isVideo = isVideoFile(path); - if (filter == Images) { - QImageReader reader(path); - if (!reader.canRead()) { + if (filter == Images && !isImage) continue; - } + if (filter == Videos && !isVideo) + continue; + if (filter == ImagesAndVideos && !isImage && !isVideo) + continue; + + newPaths.insert(path); } - newPaths.insert(path); + } else { + // fallback for Files / Dirs / All + QDir::Filters dirFilters; + if (filter == Files) + dirFilters = QDir::Files; + else if (filter == Dirs) + dirFilters = QDir::Dirs | QDir::NoDotAndDotDot; + else + dirFilters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot; + + if (showHidden) + dirFilters |= QDir::Hidden; + + QDirIterator iter(dir, nameFilters, dirFilters, flags); + while (iter.hasNext()) { + if (promise.isCanceled()) + return; + newPaths.insert(iter.next()); + } } - if (promise.isCanceled() || newPaths == oldPaths) { - return; + if (!promise.isCanceled() && newPaths != oldPaths) { + promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths)); } - - promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths)); }); - if (m_futures.contains(dir)) { + if (m_futures.contains(dir)) m_futures[dir].cancel(); - } m_futures.insert(dir, future); const auto watcher = new QFutureWatcher, QSet>>(this); - - connect(watcher, &QFutureWatcher, QSet>>::finished, this, [dir, watcher, this]() { + connect(watcher, &QFutureWatcher, QSet>>::finished, this, [=]() { m_futures.remove(dir); if (!watcher->future().isResultReadyAt(0)) { diff --git a/plugin/src/Caelestia/Models/filesystemmodel.hpp b/plugin/src/Caelestia/Models/filesystemmodel.hpp index cf8eae822..44cadc9aa 100644 --- a/plugin/src/Caelestia/Models/filesystemmodel.hpp +++ b/plugin/src/Caelestia/Models/filesystemmodel.hpp @@ -78,6 +78,8 @@ class FileSystemModel : public QAbstractListModel { enum Filter { NoFilter, Images, + Videos, + ImagesAndVideos, Files, Dirs }; diff --git a/services/Wallpapers.qml b/services/Wallpapers.qml index cb96bc565..db6a533a1 100644 --- a/services/Wallpapers.qml +++ b/services/Wallpapers.qml @@ -76,7 +76,7 @@ Searcher { recursive: true path: Paths.wallsdir - filter: FileSystemModel.Images + filter: FileSystemModel.ImagesAndVideos } Process {