diff --git a/bin/omarchy-bar-text-color b/bin/omarchy-bar-text-color index a2ce8cde..35cd0ead 100755 --- a/bin/omarchy-bar-text-color +++ b/bin/omarchy-bar-text-color @@ -109,7 +109,10 @@ right) ;; esac -pixel=$(magick "$background_path" -auto-orient \ +# Sample the first frame only. Without the selector a video background makes +# ImageMagick decode the whole file and emit one value per frame, and the match +# below then fails into the fallback colour. +pixel=$(magick "$background_path[0]" -auto-orient \ -resize "${screen_width}x${screen_height}^" \ -gravity center -extent "${screen_width}x${screen_height}" \ -gravity NorthWest -crop "$crop" +repage \ diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 8c316462..8305cb0f 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -75,8 +75,9 @@ fi selection_file=$(mktemp) done_file=$(mktemp) pending_file=$(mktemp) +pending_video_file=$(mktemp) rm -f "$done_file" -trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT +trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT image_dirs_env="" for dir in "${image_dirs[@]}"; do @@ -111,8 +112,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1) rows_cache_file="$cache_dir/$cache_key.rows" rows_signature_file="$cache_dir/$cache_key.signature" rows_fast_signature_file="$cache_dir/$cache_key.fast-signature" -rows_signature="v3"$'\n' -rows_fast_signature="v2"$'\n' +rows_signature="v4"$'\n' +rows_fast_signature="v3"$'\n' rows_cacheable=true rows_cache_hit=false image_files=() @@ -133,17 +134,25 @@ else image_files+=("$image") image_signature=$(stat -Lc '%s:%Y' "$image") || continue rows_signature+="$image:$image_signature"$'\n' - done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z) + done < <(find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null | sort -z) fi done fi +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + generate_thumbnail() { local image="$1" local thumbnail="$2" local lock="$thumbnail.lock" local lock_fd local tmp="$thumbnail.$$.jpg" + local thumbnail_command # Older releases used directories as locks, which could survive a killed # generator and block this thumbnail forever. Only reap aged ones, so a @@ -161,13 +170,29 @@ generate_thumbnail() { [[ -f $thumbnail ]] && return - # Callers fan out one generator per image, so keep each vips single-threaded. - # Close the lock fd for vips: an orphaned or hung vips must not keep holding - # the lock after this shell is killed. - if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then + # Callers fan out one generator per file, so keep each image conversion + # single-threaded. ffmpegthumbnailer provides a still preview for videos. + if is_video_path "$image"; then + # Videos are generated before the picker opens, so one unreadable or + # stalled file must not hold it shut. A failed run drops the row. + thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8) + else + thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]") + fi + + # Close the lock fd for the converter: an orphaned or hung child must not + # keep holding the lock after this shell is killed. + if "${thumbnail_command[@]}" {lock_fd}>&-; then mv -f "$tmp" "$thumbnail" else + status=$? rm -f "$tmp" "$thumbnail" + # Remember a video the converter rejected, so it costs nothing on the next + # open. The key covers size and mtime, so a repaired file starts clean. A + # timeout is left to retry: the machine may only have been busy. + if is_video_path "$image" && (( status != 124 && status != 137 )); then + : >"$thumbnail.failed" + fi fi } @@ -186,7 +211,17 @@ thumbnail_for() { thumbnail="$cache_dir/$hash.jpg" if [[ ! -f $thumbnail ]]; then - if [[ $lazy_thumbnails == true && $cache_only != true ]]; then + # A video that already failed to convert has no row to offer. Hand the + # marker back so the caller can keep the rows uncached over its absence. + if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then + printf '%s' "$thumbnail.failed" + return + fi + + # A lazy row stands in with the media file itself, which the picker draws + # with an Image -- fine for a picture, blank for a video. Videos take the + # queue instead, which also keeps them under its narrower fan out. + if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then rows_cacheable=false if [[ $prepare_only != true ]]; then @@ -197,19 +232,35 @@ thumbnail_for() { return fi - printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + if is_video_path "$image"; then + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + else + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + fi fi printf '%s' "$thumbnail" } -# Generate every queued thumbnail at once; each vips run is single-threaded. +# Each vips run is single-threaded, so still images can fill every core. +# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan +# out of those would put a codec thread pool on every core at once. drain_pending_thumbnails() { - [[ -s $pending_file ]] || return 0 + local video_jobs - export -f generate_thumbnail - xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ - bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + export -f generate_thumbnail is_video_path + + if [[ -s $pending_file ]]; then + xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi + + if [[ -s $pending_video_file ]]; then + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi } if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then @@ -219,6 +270,12 @@ elif [[ $rows_cache_hit != true ]]; then for image in "${image_files[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue + # Cached rows are trusted on the directory's mtime alone, which a file + # repaired in place never changes. Leave them uncached instead. + if [[ $thumbnail == *.failed ]]; then + rows_cacheable=false + continue + fi if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then rows_cacheable=false fi @@ -232,7 +289,7 @@ elif [[ $rows_cache_hit != true ]]; then drain_pending_thumbnails - if [[ -s $pending_file ]]; then + if [[ -s $pending_file || -s $pending_video_file ]]; then pruned="" while IFS=$'\t' read -r row_image row_thumbnail; do if [[ ! -e $row_thumbnail ]]; then diff --git a/bin/omarchy-theme-bg-next b/bin/omarchy-theme-bg-next index dfb3d3f0..f9907d7a 100755 --- a/bin/omarchy-theme-bg-next +++ b/bin/omarchy-theme-bg-next @@ -10,7 +10,8 @@ CURRENT_BACKGROUND_LINK="$HOME/.local/state/omarchy/current/background" mapfile -d '' -t BACKGROUNDS < <( find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGROUNDS_PATH" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) TOTAL=${#BACKGROUNDS[@]} diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 45b420df..835321d7 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -1,11 +1,11 @@ #!/bin/bash -# omarchy:summary=Set the current background image -# omarchy:args= +# omarchy:summary=Set the current background image or video +# omarchy:args= # omarchy:examples=omarchy theme bg set ~/Pictures/background.png if [[ -z $1 ]]; then - echo "Usage: omarchy-theme-bg-set " >&2 + echo "Usage: omarchy-theme-bg-set " >&2 exit 1 fi @@ -17,7 +17,7 @@ if [[ ! -f $BACKGROUND ]]; then exit 1 fi -# Create symlink to the new background +# Create symlink to the new background media ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK" # Update the live shell background immediately when it is running. The diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index b6733803..614a8a1b 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -48,12 +48,17 @@ shell_ipc() { timeout 2 omarchy-shell "$@" >/dev/null 2>&1 } +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + snapshot_background_path() { local background="$1" local name="$2" local snapshot extension [[ -f $background ]] || return + is_video_path "$background" && return mkdir -p "$BACKGROUND_TRANSITION_CACHE" extension=${background##*.} @@ -69,20 +74,35 @@ snapshot_current_background() { snapshot_background_path "$current_background" "previous" } +background_transition_uses_snapshots() { + local next_background="$1" + local current_background + + current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + ! is_video_path "$current_background" && ! is_video_path "$next_background" +} + choose_theme_background() { + local theme_path="${1:-$CURRENT_THEME_PATH}" + local current_theme_backgrounds="$CURRENT_THEME_PATH/backgrounds" local backgrounds=() local current_background index next_index i CHOSEN_THEME_BACKGROUND="" mapfile -d '' -t backgrounds < <( - find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$theme_path/backgrounds/" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) (( ${#backgrounds[@]} > 0 )) || return 1 current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + if [[ $theme_path != $CURRENT_THEME_PATH && ${current_background%/*} == $current_theme_backgrounds ]]; then + current_background="$theme_path/backgrounds/${current_background##*/}" + fi + index=-1 for i in "${!backgrounds[@]}"; do if [[ ${backgrounds[$i]} == $current_background ]]; then @@ -99,6 +119,16 @@ choose_theme_background() { fi } +choose_staged_theme_background() { + local next_theme_backgrounds="$NEXT_THEME_PATH/backgrounds" + + choose_theme_background "$NEXT_THEME_PATH" || return 1 + + if [[ ${CHOSEN_THEME_BACKGROUND%/*} == $next_theme_backgrounds ]]; then + CHOSEN_THEME_BACKGROUND="$CURRENT_THEME_PATH/backgrounds/${CHOSEN_THEME_BACKGROUND##*/}" + fi +} + set_theme_background_link() { choose_theme_background || return 1 ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK" @@ -107,14 +137,19 @@ set_theme_background_link() { set_theme_background() { local new_background new_background_snapshot - if ! choose_theme_background; then - omarchy-notification-send "No background was found for theme" -t 2000 - shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true - return + if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then + if ! choose_theme_background; then + omarchy-notification-send "No background was found for theme" -t 2000 + shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true + return + fi fi new_background="$CHOSEN_THEME_BACKGROUND" - new_background_snapshot=$(snapshot_background_path "$new_background" "next") + new_background_snapshot="" + if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then + new_background_snapshot=$(snapshot_background_path "$new_background" "next") + fi if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \ @@ -283,9 +318,19 @@ fi # Generate dynamic configs omarchy-theme-set-templates +CHOSEN_THEME_BACKGROUND="" OLD_BACKGROUND_SNAPSHOT="" +BACKGROUND_TRANSITION_SNAPSHOTS=true if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then - OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + # Resolve the staged choice while the old theme still exists. Video changes + # switch directly to the durable path in QML, so neither side needs a copy. + if choose_staged_theme_background; then + if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + else + BACKGROUND_TRANSITION_SNAPSHOTS=false + fi + fi fi # Swap next theme in as current diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index 6d014dd5..49bc127c 100755 --- a/bin/omarchy-theme-switcher +++ b/bin/omarchy-theme-switcher @@ -22,7 +22,7 @@ find_preview() { local theme_path="$1" local preview preview_name - for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp; do + for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp preview.mp4 preview.m4v preview.mov preview.webm preview.mkv preview.avi; do preview=$(find -L "$theme_path" -maxdepth 1 -type f -iname "$preview_name" -print -quit 2>/dev/null) if [[ -n $preview ]]; then @@ -32,7 +32,10 @@ find_preview() { done if [[ -d $theme_path/backgrounds ]]; then - find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1 + find -L "$theme_path/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print 2>/dev/null | sort | head -n 1 fi } @@ -48,7 +51,7 @@ add_theme_preview() { ln -s "$preview" "$preview_dir/$theme_name.$extension" } -fast_signature="v1"$'\n' +fast_signature="v2"$'\n' for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do if [[ -d $theme_dir ]]; then fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' @@ -104,7 +107,7 @@ fi current_theme=$(cat "$HOME/.local/state/omarchy/current/theme.name" 2>/dev/null) selected_preview="" -for extension in png jpg jpeg webp gif bmp; do +for extension in png jpg jpeg webp gif bmp mp4 m4v mov webm mkv avi; do if [[ -e $preview_dir/$current_theme.$extension ]]; then selected_preview="$preview_dir/$current_theme.$extension" break diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2e9bd759..4dedd961 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -2078,7 +2078,10 @@ for file in \ done ln -snf "$HOME/.local/state/omarchy/current/theme/btop.theme" "$HOME/.config/btop/themes/current.theme" if [[ ! -e $HOME/.local/state/omarchy/current/background && -d $HOME/.local/state/omarchy/current/theme/backgrounds ]]; then - background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) | sort | head -n1) + background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + | sort | head -n1) [[ -n ${background:-} ]] && ln -snf "$background" "$HOME/.local/state/omarchy/current/background" fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 03af6299..76e74110 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -108,6 +108,8 @@ ttfx qemu-user-static-binfmt qrencode qt6-imageformats +qt6-multimedia +qt6-multimedia-ffmpeg quickshell ripgrep ruby diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 6edec391..f2cd51eb 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,4 +4,6 @@ Every theme ships with its own set of backgrounds, and you can add extras of you You can do this most easily by going to _Install > Style > Background_ in the Omarchy Menu. That'll bring up the folder where the backgrounds for that theme is stored. Hit `Super + Shift + F` to start another file manager, find your background, copy it over. Now it'll be included in the choices of backgrounds you can select between using `Super + Ctrl + Space`. +Backgrounds can be videos as well as stills. Drop an `mp4`, `m4v`, `mov`, `webm`, `mkv`, or `avi` file in the same folder and it appears alongside the images, playing on a loop. Only your first monitor's wallpaper plays a video's sound track, through the default audio output at the system volume, and the lock screen stays silent. Playback stops on its own whenever nothing can see it — while a fullscreen window covers that monitor, while the screensaver is up, and once a locked screen has gone dark — but a video wallpaper still costs far more power than a still one, and each monitor decodes its own copy. + You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. diff --git a/migrations/1786609204.sh b/migrations/1786609204.sh new file mode 100644 index 00000000..b7833092 --- /dev/null +++ b/migrations/1786609204.sh @@ -0,0 +1,3 @@ +echo "Install native video wallpaper playback dependencies" + +omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index ca265acd..14af44fe 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -44,6 +44,10 @@ QtObject { return "file://" + String(path).split("/").map(encodeURIComponent).join("/") } + function isVideoPath(path) { + return /\.(mp4|m4v|mov|webm|mkv|avi)$/i.test(String(path || "")) + } + // Single-quote a string for bash. The replace handles embedded single // quotes by closing, escaping, and re-opening the literal. function shellQuote(value) { diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml new file mode 100644 index 00000000..b281ae22 --- /dev/null +++ b/shell/Ui/BackgroundMedia.qml @@ -0,0 +1,87 @@ +import QtQuick +import qs.Commons + +Item { + id: root + + property string path: "" + property int version: 0 + property bool playbackEnabled: true + property bool audioEnabled: false + // Bumped when the file behind an unchanged path may have been replaced. + // Images cache-bust through version; a video is rebuilt, since FFmpeg + // would read a query as part of the filename. + property int reloads: 0 + property bool reloading: false + readonly property var current: video ? videoLoader.item : imageLoader.item + readonly property bool ready: current ? current.ready : false + readonly property bool video: Util.isVideoPath(path) + // Cache-bust images selected in a running lock session. FFmpeg treats the + // query as part of a local filename, so videos must keep their plain URL. + // Each URL is empty for the other kind, so a switch never hands the still + // loader a video, or the player a still, in the moment before it unloads. + // Both test the path directly: going through `video` lets a URL evaluate + // against the stale flag and leak the wrong file for one pass. + readonly property url imageUrl: path && !Util.isVideoPath(path) ? Util.fileUrl(path) + (version ? "?v=" + version : "") : "" + readonly property url videoUrl: path && Util.isVideoPath(path) ? Util.fileUrl(path) : "" + + Loader { + id: imageLoader + anchors.fill: parent + active: root.path !== "" && !root.video + sourceComponent: imageComponent + } + + // Loaded by URL rather than from a Component here, so QtMultimedia and its + // audio dependency closure never map into a session that only shows images. + Loader { + id: videoLoader + anchors.fill: parent + active: root.path !== "" && root.video && !root.reloading + source: "BackgroundVideo.qml" + } + + onReloadsChanged: { + if (!video) return + reloading = true + Qt.callLater(function() { root.reloading = false }) + } + + // A player on its way out keeps its source: pushing an empty one starts a + // load of nothing that its destructor then cancels, which FFmpeg logs. + Binding { + target: videoLoader.item + property: "mediaSource" + value: root.videoUrl + when: videoLoader.item !== null && Util.isVideoPath(root.path) + restoreMode: Binding.RestoreNone + } + + Binding { + target: videoLoader.item + property: "playbackEnabled" + value: root.playbackEnabled + when: videoLoader.item !== null + } + + Binding { + target: videoLoader.item + property: "audioEnabled" + value: root.audioEnabled + when: videoLoader.item !== null + } + + Component { + id: imageComponent + + Image { + readonly property bool ready: status === Image.Ready + source: root.imageUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: root.version === 0 + sourceSize.width: root.version > 0 ? width : 0 + sourceSize.height: root.version > 0 ? height : 0 + } + } +} diff --git a/shell/Ui/BackgroundVideo.qml b/shell/Ui/BackgroundVideo.qml new file mode 100644 index 00000000..519e8bd9 --- /dev/null +++ b/shell/Ui/BackgroundVideo.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtMultimedia + +// Deliberately a bare MediaPlayer and VideoOutput rather than the Video +// convenience type: Video always builds an AudioOutput, and a muted sink still +// decodes the audio stream and opens an audio client on every output. +Item { + id: root + + property url mediaSource: "" + property bool playbackEnabled: true + property bool audioEnabled: false + property int mediaGeneration: 0 + property bool priming: false + property int primingGeneration: -1 + property bool frameReceived: false + readonly property bool ready: player.hasVideo + + onMediaSourceChanged: { + mediaGeneration += 1 + priming = false + primingGeneration = -1 + frameReceived = false + primePauseTimer.stop() + framePauseTimer.stop() + output.clearOutput() + } + + onPlaybackEnabledChanged: { + priming = false + frameReceived = false + primePauseTimer.stop() + framePauseTimer.stop() + if (playbackEnabled) player.play() + else player.pause() + } + + function pauseAfterPrimedFrame() { + if (!priming + || root.playbackEnabled + || primingGeneration !== root.mediaGeneration) return + + priming = false + primePauseTimer.stop() + framePauseTimer.stop() + player.pause() + } + + // A paused MediaPlayer can load a source without presenting its first frame. + // Prime it until VideoOutput receives a frame, with a timeout so a stalled + // decoder cannot keep running indefinitely on battery. + Timer { + id: primePauseTimer + interval: 1000 + repeat: false + + onTriggered: root.pauseAfterPrimedFrame() + } + + // Receiving a frame means the decoder has produced it, but the scene graph + // may not have committed it yet. Give VideoOutput a render cycle before + // pausing so the first frame is not lost on a source switch. + Timer { + id: framePauseTimer + interval: 50 + repeat: false + + onTriggered: root.pauseAfterPrimedFrame() + } + + VideoOutput { + id: output + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectCrop + } + + // Sound is opted into per output: with a player per monitor, every output + // playing the track would layer copies of it. The sink is only built once + // the media reports a sound track, so a silent file never opens an audio + // client or its threads. Priming a paused player must not be heard. + Loader { + id: audioLoader + active: root.audioEnabled && player.hasAudio + sourceComponent: AudioOutput { + muted: root.priming || !root.playbackEnabled + } + } + + MediaPlayer { + id: player + source: root.mediaSource + videoOutput: output + audioOutput: audioLoader.item + loops: MediaPlayer.Infinite + autoPlay: root.playbackEnabled + onMediaStatusChanged: { + if (mediaStatus !== MediaPlayer.LoadedMedia) return + + if (!root.playbackEnabled) { + root.priming = true + root.primingGeneration = root.mediaGeneration + root.frameReceived = false + primePauseTimer.restart() + } + player.play() + } + } + + Connections { + target: output.videoSink + function onVideoFrameChanged() { + if (player.mediaStatus !== MediaPlayer.BufferedMedia) return + if (!root.priming + || root.playbackEnabled + || root.primingGeneration !== root.mediaGeneration + || root.frameReceived) return + + root.frameReceived = true + framePauseTimer.restart() + } + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index b25bf5ca..63e82639 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -3,6 +3,8 @@ module qs.Ui BarIndicator 1.0 BarIndicator.qml BarIconButton 1.0 BarIconButton.qml BarWidget 1.0 BarWidget.qml +BackgroundMedia 1.0 BackgroundMedia.qml +BackgroundVideo 1.0 BackgroundVideo.qml BorderOverlay 1.0 BorderOverlay.qml BorderSurface 1.0 BorderSurface.qml Button 1.0 Button.qml diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 21e1b0a9..dea93a4c 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -16,6 +17,7 @@ Item { property string currentBackground: "" property string displayedBackground: "" + property int displayedReloads: 0 property string incomingBackground: "" property string oldBackground: "" property bool finishingTransition: false @@ -26,6 +28,27 @@ Item { property string pendingShellRaw: "" property real revealProgress: 1 + // Injected by the first-party service loader; used to reach the lock and idle + // services so playback can stop whenever nothing can see the wallpaper. + property var shell: null + + // Stop a video wallpaper's decoding whenever it is covered. Qt's FFmpeg + // engine drives its own clock, so an unseen player keeps decoding until it + // is told not to — a locked laptop would otherwise decode until it died. + readonly property var lockService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.lock") : null + readonly property var idleService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.idle") : null + readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null + readonly property bool lockActive: lockService ? lockService.locked : false + readonly property bool screensaverActive: idleService ? idleService.screensaverWindowCount > 0 : false + readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false + // A lock or a screensaver covers every output, so it is decided once here. + // Fullscreen is decided per output below, because it only covers its own. + readonly property bool sessionObscured: lockActive || screensaverActive + + function isVideo(path) { + return Util.isVideoPath(path) + } + function imageUrl(path) { return Util.fileUrl(path) } @@ -50,10 +73,15 @@ Item { revealAnimation.stop() finishingTransition = false - if (instant || !displayedBackground) { + // Video frames are not fed through the image-only reveal stack. Switching + // instantly also avoids decoding two full videos during a transition. + if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" - displayedBackground = path + // A theme switch can replace the file behind an unchanged path, which + // an unchanged property would never pick up. + if (displayedBackground === finalPath) displayedReloads += 1 + displayedBackground = finalPath revealProgress = 1 return } @@ -196,11 +224,24 @@ Item { color: "transparent" // Keep render updates enabled. The background layer has been observed to // lose its committed buffer while parked with updatesEnabled=false, - // leaving a black desktop until omarchy-shell is restarted. The wallpaper - // itself is static, so this favors correctness over a small render-loop - // optimization. + // leaving a black desktop until omarchy-shell is restarted. A still + // wallpaper costs nothing to keep enabled, and a video one is throttled + // by pausing playback rather than by parking the layer. updatesEnabled: true + // Pausing every wallpaper for one fullscreen window would freeze the one + // still on show next to it, which costs a viewer more than it saves. The + // workspace on show here knows whether a fullscreen window covers it, + // wherever focus happens to be. + readonly property var hyprlandMonitor: Hyprland.monitorFor(modelData) + readonly property var visibleWorkspace: hyprlandMonitor ? hyprlandMonitor.activeWorkspace : null + readonly property bool fullscreenHere: visibleWorkspace ? visibleWorkspace.hasFullscreen : false + + // A sound track plays from one output only, or every monitor would + // layer its own copy of it. + readonly property bool firstScreen: Quickshell.screens.length > 0 + && String(Quickshell.screens[0].name || "") === String(modelData.name || "") + property bool maskReady: false function maybeStartReveal() { @@ -218,15 +259,15 @@ Item { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore - Image { + BackgroundMedia { id: base anchors.fill: parent - source: root.imageUrl(root.displayedBackground) - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - onStatusChanged: { - if (status === Image.Ready && root.finishingTransition) { + path: root.displayedBackground + reloads: root.displayedReloads + playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere + audioEnabled: panel.firstScreen + onReadyChanged: { + if (ready && root.finishingTransition) { root.incomingBackground = "" root.oldBackground = "" root.finishingTransition = false diff --git a/shell/plugins/image-picker/list.sh b/shell/plugins/image-picker/list.sh index a30e4743..0fb74117 100755 --- a/shell/plugins/image-picker/list.sh +++ b/shell/plugins/image-picker/list.sh @@ -3,12 +3,18 @@ image_dirs=${1:-} cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector index_file="$cache_dir/index.tsv" +pending_video_file=$(mktemp) mkdir -p "$cache_dir" +trap 'rm -f "$pending_video_file"' EXIT -thumbnail_for() { +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + +thumbnail_path_for() { local image="$1" - local signature hash thumbnail legacy_hash + local signature hash signature=$(stat -Lc '%s:%Y' "$image") || return hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null) @@ -17,9 +23,58 @@ thumbnail_for() { hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1) fi - thumbnail="$cache_dir/$hash.jpg" + printf '%s/%s.jpg' "$cache_dir" "$hash" +} - if [[ ! -f $thumbnail ]]; then +generate_video_thumbnail() { + local image="$1" + local thumbnail="$2" + local lock="$thumbnail.lock" + local lock_fd + local tmp="$thumbnail.$$.jpg" + + if [[ -d $lock ]] && (( $(date +%s) - $(stat -c '%Y' "$lock" 2>/dev/null || date +%s) > 120 )); then + rmdir "$lock" 2>/dev/null + fi + + exec {lock_fd}>"$lock" || return + flock -w 30 "$lock_fd" || return + rm -f "$thumbnail".*.jpg + + [[ -f $thumbnail ]] && return + + if timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8 {lock_fd}>&-; then + mv -f "$tmp" "$thumbnail" + else + status=$? + rm -f "$tmp" "$thumbnail" + # Remember a rejected video so it costs nothing on the next scan; the key + # covers size and mtime, so a repaired file starts clean. A timeout is + # left to retry: the machine may only have been busy. + (( status == 124 || status == 137 )) || : >"$thumbnail.failed" + return 1 + fi +} + +drain_pending_video_thumbnails() { + local video_jobs + + [[ -s $pending_video_file ]] || return 0 + + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + export -f generate_video_thumbnail + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_video_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true +} + +thumbnail_for() { + local image="$1" + local thumbnail legacy_hash + + thumbnail=$(thumbnail_path_for "$image") || return + + if [[ ! -f $thumbnail ]] && ! is_video_path "$image"; then # Older on-demand picker code keyed fallback thumbnails by file content. # Keep finding those if a user still has them cached. legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1) @@ -28,17 +83,31 @@ thumbnail_for() { if [[ -f $thumbnail ]]; then printf '%s' "$thumbnail" - else + elif ! is_video_path "$image"; then printf '%s' "$image" fi } -while IFS= read -r dir; do - [[ -n $dir && -d $dir ]] || continue - find -L "$dir" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ - -print0 2>/dev/null -done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do +mapfile -d '' -t images < <( + while IFS= read -r dir; do + [[ -n $dir && -d $dir ]] || continue + find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null + done <<<"$image_dirs" | sort -z +) + +for image in "${images[@]}"; do + if is_video_path "$image"; then + thumbnail=$(thumbnail_path_for "$image") || continue + [[ -f $thumbnail || -f $thumbnail.failed ]] || printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + fi +done + +drain_pending_video_thumbnails + +for image in "${images[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue printf '%s\t%s\n' "$image" "$thumbnail" diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index c2deae0f..e7a430ac 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -14,6 +14,11 @@ Item { property int failedAttempts: 0 property bool inputEnabled: true property bool loadBackground: true + // A locked session blanks the displays after a few seconds. Nothing is + // visible from then until the user wakes it, so a video must not keep + // decoding through what is usually the longest part of a lock. + property bool displaysBlank: false + property bool powerSaverActive: false property string passwordText: "" property bool syncingPasswordText: false @@ -43,15 +48,6 @@ Item { signal clearFailureRequested() signal wakeRequested() - // Cache-busts the lock background by appending `?v=`. Adding a query - // string keeps Image's loader happy while forcing it to reload when the - // user picks a new background mid-session. - function fileUrl(path) { - if (!path) return "" - var encoded = String(path).split("/").map(encodeURIComponent).join("/") - return "file://" + encoded + "?v=" + backgroundVersion - } - function forcePasswordFocus() { passwordInput.forceActiveFocus() } @@ -90,28 +86,34 @@ Item { anchors.fill: parent color: Color.background - Image { + BackgroundMedia { id: wallpaper anchors.fill: parent - source: root.loadBackground ? root.fileUrl(root.backgroundPath) : "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - sourceSize.width: width - sourceSize.height: height + path: root.loadBackground ? root.backgroundPath : "" + version: root.backgroundVersion + playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive } MultiEffect { anchors.fill: wallpaper - source: wallpaper + source: wallpaper.video ? null : wallpaper + visible: !wallpaper.video autoPaddingEnabled: false - blurEnabled: root.loadBackground && wallpaper.status === Image.Ready + blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 blurMax: 128 blurMultiplier: 1.25 contrast: -0.08 } + // Qt's video output cannot be sampled by MultiEffect on every renderer. + // Keep video wallpapers visible and darken them slightly for legibility. + Rectangle { + anchors.fill: wallpaper + visible: wallpaper.video + color: "#22000000" + } + MouseArea { anchors.fill: parent hoverEnabled: true diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 9ecb1cc0..94d43b68 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -31,11 +31,21 @@ Item { property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" + property bool displaysBlank: false + // displaysBlank tracks what the lock asked for; Hyprland reports what each + // panel actually did. While a video is on show the two are reconciled, so a + // blank that failed keeps playing and a panel woken behind the lock's back + // (a resume that kept the same outputs) resumes instead of freezing. + property var monitorDpms: ({}) + property bool monitorDpmsKnown: false + readonly property bool videoBackground: Util.isVideoPath(backgroundPath) property bool strandedLock: false property bool strandedLockResolved: false readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating + readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null + readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false function realScreenCount() { var screens = Quickshell.screens || [] @@ -165,14 +175,42 @@ Item { } function runWake() { + root.displaysBlank = false + root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() } function runBlank() { + root.displaysBlank = true + root.monitorDpmsKnown = false if (!blankProcess.running) blankProcess.running = true } + function screenBlank(screenName) { + var name = String(screenName || "") + if (!monitorDpmsKnown || !(name in monitorDpms)) return displaysBlank + return !monitorDpms[name] + } + + function applyMonitorDpms(text) { + var monitors + try { + monitors = JSON.parse(String(text || "")) + } catch (error) { + return + } + if (!Array.isArray(monitors)) return + + var dpms = {} + for (var i = 0; i < monitors.length; i++) { + var monitor = monitors[i] + if (monitor && monitor.name && !monitor.disabled) dpms[String(monitor.name)] = !!monitor.dpmsStatus + } + monitorDpms = dpms + monitorDpmsKnown = true + } + function submitPassword(value) { var password = String(value || "") if (!lockRequested || authenticatingPassword || password.length === 0) return @@ -276,6 +314,8 @@ Item { failedAttempts: root.failedAttempts inputEnabled: root.lockRequested loadBackground: root.locked + displaysBlank: root.screenBlank(lockSurface.screen ? lockSurface.screen.name : "") + powerSaverActive: root.powerSaverActive passwordText: root.enteredPassword onPasswordTextEdited: function(password) { root.enteredPassword = password } onSubmitPassword: function(password) { root.submitPassword(password) } @@ -306,6 +346,7 @@ Item { failedAttempts: 0 inputEnabled: false loadBackground: root.previewVisible + powerSaverActive: root.powerSaverActive passwordText: "" } @@ -411,6 +452,31 @@ Item { command: ["bash", "-c", "omarchy-brightness-keyboard off; omarchy-brightness-display off"] } + // Quickshell exposes no DPMS signal, so the panel state is polled while a + // video is the locked wallpaper. A wake or blank request drops the last + // answer, so its optimistic state applies until the next poll confirms it. + Process { + id: monitorDpmsProcess + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: root.applyMonitorDpms(text) + } + } + + Timer { + id: monitorDpmsTimer + interval: 3000 + repeat: true + triggeredOnStart: true + running: root.locked && root.videoBackground + onTriggered: { + if (!monitorDpmsProcess.running) monitorDpmsProcess.running = true + } + onRunningChanged: { + if (!running) root.monitorDpmsKnown = false + } + } + Timer { id: idleBlankTimer interval: 5000 @@ -467,6 +533,10 @@ Item { Connections { target: Quickshell function onScreensChanged() { + // A panel coming back is a display turning on that runWake did not ask + // for, so the blank state has to be given up here or a visible lock + // wallpaper stays frozen until the next keypress. + root.displaysBlank = false root.requestSessionLock() // A monitor still coming up has no workspace, so cannot answer yet. diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 37fdf5c7..a1b01cff 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -12,6 +12,8 @@ Item { readonly property int batteryThreshold: 10 property string pendingPowerSource: "" + property string activePowerProfile: "" + readonly property bool powerSaverOnBattery: UPower.onBattery && activePowerProfile === "power-saver" PersistentProperties { id: persisted @@ -53,11 +55,38 @@ Item { powerProfileProcess.running = true } + function refreshPowerProfile() { + if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true + } + Process { id: warningProcess } Process { id: powerProfileProcess - onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + onExited: { + if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + root.refreshPowerProfile() + } + } + + Process { + id: powerProfileReadProcess + command: ["powerprofilesctl", "get"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.activePowerProfile = String(text || "").trim() + } + } + + Timer { + // powerprofilesctl has no portable monitor subcommand; keep profile changes + // visible to consumers such as the wallpaper service without requiring the + // power panel to be open. + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refreshPowerProfile() } Timer { @@ -73,6 +102,9 @@ Item { function onOnBatteryChanged() { root.checkBattery() root.applyPowerProfile() + root.refreshPowerProfile() } } + + Component.onCompleted: root.refreshPowerProfile() } diff --git a/shell/shell.qml b/shell/shell.qml index 7e1cf75d..37a0fe6e 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -272,6 +272,10 @@ ShellRoot { property var _services: ({}) + // Reassigned as each service registers, so a binding that reads this before + // looking a service up by id re-evaluates once that service exists. + readonly property var services: _services + function serviceFor(pluginId) { return _services[String(pluginId)] || null } diff --git a/test/shell.d/bar-text-color-test.sh b/test/shell.d/bar-text-color-test.sh index b7626c2b..d22a289c 100755 --- a/test/shell.d/bar-text-color-test.sh +++ b/test/shell.d/bar-text-color-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command magick +require_command ffmpeg TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT @@ -28,3 +29,15 @@ pass "transparent bar text keeps text color on dark wallpaper" result=$(HOME="$TMPDIR" omarchy-bar-text-color top 20 '#ffffff' '#101010' --background "$TMPDIR/missing.png" --screen 100x100) [[ $result == "#ffffff" ]] || fail "transparent bar text falls back to text color when sampling fails" "expected #ffffff, got $result" pass "transparent bar text falls back to text color when sampling fails" + +# A video background must be sampled one frame at a time. Reading the whole file +# emits a value per frame, which parses as nothing and silently falls back — +# and decodes the entire wallpaper to find that out. +light_top_video="$TMPDIR/light-top.mp4" +ffmpeg -y -f lavfi -i "testsrc=size=640x360:rate=10:duration=2" \ + -vf "drawbox=x=0:y=0:w=640:h=40:color=0xf5f5f5:t=fill" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p "$light_top_video" -loglevel error + +result=$(HOME="$TMPDIR" omarchy-bar-text-color top 40 '#ffffff' '#101010' --background "$light_top_video" --screen 640x360) +[[ $result == "#101010" ]] || fail "transparent bar text samples one frame of a video wallpaper" "expected #101010, got $result" +pass "transparent bar text samples one frame of a video wallpaper" diff --git a/test/shell.d/menu-images-test.sh b/test/shell.d/menu-images-test.sh index ac20a8ca..179dcb6b 100644 --- a/test/shell.d/menu-images-test.sh +++ b/test/shell.d/menu-images-test.sh @@ -69,7 +69,7 @@ PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \ fail "image menu recovers thumbnails from stranded locks" (( $(awk 'END { print NR }' "$cache_dir/$cache_key.rows") == 3 )) || fail "image menu rebuilds every row after cache invalidation" -[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v3" ]] || +[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v4" ]] || fail "image menu invalidates stale row caches" [[ ! -e $stale_tmp ]] || fail "image menu clears partial thumbnails left by killed generators" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh new file mode 100755 index 00000000..1a15dace --- /dev/null +++ b/test/shell.d/video-background-test.sh @@ -0,0 +1,366 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') + +const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8') +const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') +const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8') +const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') +const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8') +const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8') +const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8') +const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8') +const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8') +const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') +const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8') +const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') +const directImageList = fs.readFileSync(path.join(root, 'shell/plugins/image-picker/list.sh'), 'utf8') + +assert( + /function isVideoPath\(path\)[\s\S]*\.test\(String\(path \|\| ""\)\)/.test(utilQml) && + !utilQml.includes('split(/[?#]/)'), + 'shared media helper identifies video paths without truncating valid local names' +) +assert( + videoQml.includes('loops: MediaPlayer.Infinite') && + videoQml.includes('autoPlay: root.playbackEnabled') && + videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') && + /imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && + /videoUrl: path && Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) : ""/.test(mediaQml), + 'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file' +) +assert( + videoQml.includes('MediaPlayer.LoadedMedia') && + videoQml.includes('primePauseTimer') && + videoQml.includes('mediaGeneration') && + videoQml.includes('videoSink') && + videoQml.includes('onVideoFrameChanged') && + videoQml.includes('MediaPlayer.BufferedMedia') && + videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') && + videoQml.includes('interval: 1000') && + videoQml.includes('interval: 50') && + videoQml.includes('frameReceived') && + videoQml.includes('output.clearOutput()') && + !videoQml.includes('KeepLastFrame') && + /onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) && + videoQml.includes('primingGeneration') && + videoQml.includes('player.play()') && + videoQml.includes('player.pause()'), + 'paused video sources are primed to display their first frame' +) +assert( + !/^\s*import QtMultimedia/m.test(mediaQml) && + mediaQml.includes('source: "BackgroundVideo.qml"'), + 'the still-image path never imports QtMultimedia, so image-only sessions do not map it' +) +assert( + !/^\s*Video\s*\{/m.test(videoQml) && + /property bool audioEnabled: false/.test(videoQml) && + /property bool audioEnabled: false/.test(mediaQml) && + /active: root\.audioEnabled && player\.hasAudio/.test(videoQml) && + /audioOutput: audioLoader\.item/.test(videoQml) && + /muted: root\.priming \|\| !root\.playbackEnabled/.test(videoQml) && + /property: "audioEnabled"\s*\n\s*value: root\.audioEnabled/.test(mediaQml) && + /firstScreen: Quickshell\.screens\.length > 0\s*\n\s*&& String\(Quickshell\.screens\[0\]\.name/.test(backgroundQml) && + backgroundQml.includes('audioEnabled: panel.firstScreen') && + !lockQml.includes('audioEnabled'), + 'a sound track plays from the first monitor only, a silent file builds no audio output, and the lock stays quiet' +) +assert( + /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && + !videoQml.includes('Component.onDestruction'), + 'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel' +) +assert( + !mediaQml.includes('mipmap'), + 'the shared image path leaves mipmapping off, as the desktop background had it' +) +assert( + /instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml), + 'video switches bypass the image-only reveal stack and use the durable background path' +) +assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') +assert( + lockQml.includes('source: wallpaper.video ? null : wallpaper') && + lockQml.includes('visible: !wallpaper.video') && + lockQml.includes('visible: wallpaper.video'), + 'lock screen bypasses its image effect for video output' +) +assert( + /sessionObscured:\s*lockActive \|\| screensaverActive/.test(backgroundQml) && + backgroundQml.includes('playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere') && + backgroundQml.includes('omarchy.lock') && + backgroundQml.includes('omarchy.idle') && + backgroundQml.includes('omarchy.battery'), + 'desktop playback stops while covered or on battery power-saver' +) +assert( + backgroundQml.includes('Hyprland.monitorFor(modelData)') && + /fullscreenHere: visibleWorkspace \? visibleWorkspace\.hasFullscreen : false/.test(backgroundQml) && + !backgroundQml.includes('ToplevelManager.activeToplevel'), + 'a fullscreen window pauses only the output it covers, wherever focus is' +) +assert( + /if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) && + backgroundQml.includes('reloads: root.displayedReloads') && + /active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) && + /onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml), + 'a theme switch that keeps the video path still reopens the replaced file' +) +assert( + barTextColor.includes('magick "$background_path[0]"'), + 'bar colour sampling reads one frame instead of decoding a whole video' +) +assert( + menuImages.includes('pending_video_file') && /video_jobs=\$\(\( \$\(nproc\) \/ 4 \)\)/.test(menuImages), + 'video thumbnails fan out narrower than single-threaded vips jobs' +) +assert( + themeSwitcher.includes('fast_signature="v2"'), + 'the theme preview cache rebuilds after preview discovery learned about video' +) +assert( + /lazy_thumbnails == true && \$cache_only != true \]\] && ! is_video_path/.test(menuImages), + 'a video never stands in as its own lazy thumbnail, so the fan out cap always applies' +) +assert( + /thumbnail_command=\(timeout -k \d+ \d+ ffmpegthumbnailer/.test(menuImages), + 'a stalled video cannot hold the picker shut, because its generator is time bounded' +) +assert( + directImageList.includes('generate_video_thumbnail') && + /timeout -k \d+ \d+ ffmpegthumbnailer/.test(directImageList) && + /if \[\[ ! -f \$thumbnail \]\] && ! is_video_path/.test(directImageList), + 'a direct picker scan generates bounded video thumbnails without content-hashing the media' +) +assert( + themeSet.includes('choose_staged_theme_background') && + themeSet.includes('background_transition_uses_snapshots') && + themeSet.includes('BACKGROUND_TRANSITION_SNAPSHOTS=false') && + /if \[\[ \$BACKGROUND_TRANSITION_SNAPSHOTS == "true" \]\]/.test(themeSet) && + /if \[\[ -z \$CHOSEN_THEME_BACKGROUND \|\| ! -f \$CHOSEN_THEME_BACKGROUND \]\]/.test(themeSet), + 'theme changes disable both transition snapshots whenever either side is a video' +) +assert( + /function onScreensChanged\(\) \{[\s\S]*?root\.displaysBlank = false/.test(lockService), + 'a display coming back gives up the blank state instead of freezing a visible wallpaper' +) +assert( + lockService.includes('function screenBlank(screenName)') && + lockService.includes('function applyMonitorDpms(text)') && + lockService.includes('command: ["hyprctl", "monitors", "-j"]') && + lockService.includes('running: root.locked && root.videoBackground') && + /displaysBlank: root\.screenBlank\(lockSurface\.screen/.test(lockService) && + /function runWake\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService) && + /function runBlank\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService), + 'a locked video wallpaper follows what each panel actually did, not only what the lock asked for' +) +assert( + lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') && + lockView.includes('&& !root.powerSaverActive') && + /displaysBlank: root\.screenBlank\(/.test(lockService) && + /powerSaverActive: root\.powerSaverActive/.test(lockService) && + /function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) && + /function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService), + 'the lock screen stops playback once displays go dark or power-saver is active' +) +assert( + batteryService.includes('property string activePowerProfile') && + batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') && + batteryService.includes('["powerprofilesctl", "get"]') && + batteryService.includes('interval: 2000'), + 'the battery service tracks the active power-saver profile' +) +assert( + themeSwitcher.includes("-iname '*.mp4'") && + themeSwitcher.includes('mp4 m4v mov webm mkv avi') && + themeSwitcher.includes('preview.mp4'), + 'theme switcher previews video-only themes, named preview files included' +) +assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background') +assert( + multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'), + 'existing Quattro installations receive video playback dependencies' +) +JS + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" "$test_tmp/backgrounds" "$test_tmp/generator-cache" "$test_tmp/direct-cache" +printf 'not a real video\n' >"$test_tmp/backgrounds/sample.mp4" + +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +while (( $# > 0 )); do + case "$1" in + -i) input=$2; shift 2 ;; + -o) output=$2; shift 2 ;; + *) shift ;; + esac +done +printf 'thumbnail for %s\n' "$input" >"$output" +SH +chmod +x "$test_tmp/bin/ffmpegthumbnailer" + +cat >"$test_tmp/bin/md5sum" <<'SH' +#!/bin/bash +if (( $# > 0 )); then + printf 'unexpected file hash: %s\n' "$*" >>"$MD5_FILE_CALLS" +fi +exec /usr/bin/md5sum "$@" +SH +chmod +x "$test_tmp/bin/md5sum" + +md5_file_calls="$test_tmp/md5-file-calls" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/generator-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$test_tmp/backgrounds" + +generator_thumbnail=$(find "$test_tmp/generator-cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.jpg' -print -quit) +[[ -s $generator_thumbnail ]] || fail "menu image generator creates a video thumbnail" + +generator_row=$(XDG_CACHE_HOME="$test_tmp/generator-cache" "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") +IFS=$'\t' read -r generator_row_path generator_row_thumbnail <<<"$generator_row" +[[ $generator_row_path == "$test_tmp/backgrounds/sample.mp4" && $generator_row_thumbnail == "$generator_thumbnail" ]] || \ + fail "direct picker consumes the menu image generator thumbnail" "$generator_row" + +row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") + +IFS=$'\t' read -r row_path row_thumbnail <<<"$row" +[[ $row_path == "$test_tmp/backgrounds/sample.mp4" && $row_thumbnail == *.jpg && -s $row_thumbnail ]] || \ + fail "image picker lists videos with their cached thumbnail" "$row" +[[ ! -s $md5_file_calls ]] || fail "direct video scans avoid hashing the complete media file" "$(<"$md5_file_calls")" + +failed_backgrounds="$test_tmp/failed-backgrounds" +failed_cache="$test_tmp/failed-cache" +mkdir -p "$failed_backgrounds" "$failed_cache" +printf 'broken video\n' >"$failed_backgrounds/broken.mp4" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +exit 1 +SH + +cached_row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") +[[ $cached_row == "$row" ]] || fail "direct picker reuses its cached video thumbnail" "$cached_row" + +failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds") +[[ -z $failed_rows ]] || fail "direct picker omits a video whose thumbnail fails" "$failed_rows" + +failed_marker=$(find "$failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -n $failed_marker ]] || fail "direct picker remembers a video the converter rejected" + +thumbnailer_calls="$test_tmp/thumbnailer-calls" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$THUMBNAILER_CALLS" +exit 1 +SH + +failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds") +[[ -z $failed_rows && ! -e $thumbnailer_calls ]] || fail "direct picker skips a rejected video on the next scan" "$(cat "$thumbnailer_calls" 2>/dev/null)" + +generator_failed_cache="$test_tmp/generator-failed-cache" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ -s $thumbnailer_calls ]] || fail "menu image generator tries a video it has not seen" +generator_marker=$(find "$generator_failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -n $generator_marker ]] || fail "menu image generator remembers a video the converter rejected" +rm -f "$thumbnailer_calls" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ ! -e $thumbnailer_calls ]] || fail "menu image generator skips a rejected video on the next open" "$(<"$thumbnailer_calls")" + +# A repaired file gets a fresh key, so the old marker no longer applies, and +# the rows are not cached over its absence, so an in-place repair that leaves +# the directory's mtime alone is still noticed. +touch -d '2 minutes' "$failed_backgrounds/broken.mp4" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ -s $thumbnailer_calls ]] || fail "menu image generator retries a video that changed since it was rejected" + +timeout_backgrounds="$test_tmp/timeout-backgrounds" +timeout_cache="$test_tmp/timeout-cache" +mkdir -p "$timeout_backgrounds" +printf 'slow video\n' >"$timeout_backgrounds/slow.mp4" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +exit 124 +SH +timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$timeout_backgrounds") +[[ -z $timeout_rows ]] || fail "direct picker omits a video whose thumbnail timed out" "$timeout_rows" +timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed" + +grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package" +grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package" + +pass "menu image generator creates thumbnails consumed by the picker" +pass "direct picker generates and reuses still thumbnails" +pass "direct picker omits videos whose thumbnails cannot be generated" +pass "a rejected video is remembered so it costs nothing on the next open" +pass "a timed out video is left to retry" +pass "a locked video wallpaper follows the panels' real DPMS state" +pass "Qt Multimedia playback dependencies are declared" + +source <(awk ' + /^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 } + copying { print } + copying && /^}$/ { copying=0 } +' "$ROOT/bin/omarchy-theme-set") + +transition_home="$test_tmp/transition-home" +CURRENT_THEME_PATH="$transition_home/.local/state/omarchy/current/theme" +NEXT_THEME_PATH="$transition_home/.local/state/omarchy/current/next-theme" +CURRENT_BACKGROUND_LINK="$transition_home/.local/state/omarchy/current/background" +BACKGROUND_TRANSITION_CACHE="$transition_home/.cache/omarchy/background-transitions" +THEME_NAME="video-test" +HOME="$transition_home" +mkdir -p "$CURRENT_THEME_PATH/backgrounds" "$NEXT_THEME_PATH/backgrounds" "$HOME/.config/omarchy/backgrounds/$THEME_NAME" +printf 'old image\n' >"$CURRENT_THEME_PATH/backgrounds/old.png" +printf 'old image staged\n' >"$NEXT_THEME_PATH/backgrounds/old.png" +printf 'new video\n' >"$NEXT_THEME_PATH/backgrounds/new.mp4" +ln -s "$CURRENT_THEME_PATH/backgrounds/old.png" "$CURRENT_BACKGROUND_LINK" + +choose_staged_theme_background || fail "staged video background is selected before the theme swap" +expected_staged_background="$CURRENT_THEME_PATH/backgrounds/new.mp4" +[[ $CHOSEN_THEME_BACKGROUND == $expected_staged_background ]] || \ + fail "staged background resolves to its durable post-swap path" "$CHOSEN_THEME_BACKGROUND" +if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + fail "image to video theme transitions skip snapshots" +fi +background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png" || \ + fail "image to image theme transitions retain snapshots" + +rm "$CURRENT_BACKGROUND_LINK" +printf 'old video\n' >"$CURRENT_THEME_PATH/backgrounds/old.mp4" +ln -s "$CURRENT_THEME_PATH/backgrounds/old.mp4" "$CURRENT_BACKGROUND_LINK" +if background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png"; then + fail "video to image theme transitions skip snapshots" +fi + +video_snapshot=$(snapshot_background_path "$CURRENT_THEME_PATH/backgrounds/old.mp4" "video") +[[ -z $video_snapshot && ! -e $BACKGROUND_TRANSITION_CACHE ]] || fail "video files are never snapshotted" + +CHOSEN_THEME_BACKGROUND="$transition_home/disappeared.mp4" +BACKGROUND_TRANSITION_SNAPSHOTS=false +OLD_BACKGROUND_SNAPSHOT="" +colors_payload="" +shell_payload="" +shell_ipc() { :; } +set_theme_background +[[ -f $CHOSEN_THEME_BACKGROUND && $(readlink "$CURRENT_BACKGROUND_LINK") == "$CHOSEN_THEME_BACKGROUND" ]] || \ + fail "theme changes recover when a preselected background disappears" "$CHOSEN_THEME_BACKGROUND" + +pass "theme transitions skip snapshots whenever either side is a video" +pass "theme changes recover from a missing preselected background"