diff --git a/assets/wallpapers_example/86.mp4 b/assets/wallpapers_example/86.mp4 new file mode 100644 index 00000000..232309f1 Binary files /dev/null and b/assets/wallpapers_example/86.mp4 differ diff --git a/assets/wallpapers_example/skyyy.mp4 b/assets/wallpapers_example/skyyy.mp4 new file mode 100644 index 00000000..c2649841 Binary files /dev/null and b/assets/wallpapers_example/skyyy.mp4 differ diff --git a/config/settings_utils.py b/config/settings_utils.py index e95db8c9..4c6b5cec 100644 --- a/config/settings_utils.py +++ b/config/settings_utils.py @@ -45,34 +45,17 @@ def ensure_matugen_config(): with the expected settings. """ expected_config = { - "config": { - "reload_apps": True, - "wallpaper": { - "command": "swww", - "arguments": [ - "img", - "-t", - "fade", - "--transition-duration", - "0.5", - "--transition-step", - "255", - "--transition-fps", - "60", - "-f", - "Nearest", - ], - "set": True, - }, - "custom_colors": { - "red": {"color": "#FF0000", "blend": True}, - "green": {"color": "#00FF00", "blend": True}, - "yellow": {"color": "#FFFF00", "blend": True}, - "blue": {"color": "#0000FF", "blend": True}, - "magenta": {"color": "#FF00FF", "blend": True}, - "cyan": {"color": "#00FFFF", "blend": True}, - "white": {"color": "#FFFFFF", "blend": True}, - }, + 'config': { + 'reload_apps': True, + 'custom_colors': { + 'red': {'color': "#FF0000", 'blend': True}, + 'green': {'color': "#00FF00", 'blend': True}, + 'yellow': {'color': "#FFFF00", 'blend': True}, + 'blue': {'color': "#0000FF", 'blend': True}, + 'magenta': {'color': "#FF00FF", 'blend': True}, + 'cyan': {'color': "#00FFFF", 'blend': True}, + 'white': {'color': "#FFFFFF", 'blend': True} + } }, "templates": { "hyprland": { @@ -97,14 +80,13 @@ def ensure_matugen_config(): existing_config = toml.load(f) shutil.copyfile(config_path, config_path + ".bak") except toml.TomlDecodeError: - print( - f"Warning: Could not decode TOML from {config_path}. A new default config will be created." - ) - existing_config = {} # Resetear si está corrupto + print(f"Warning: Could not decode TOML from {config_path}. A new default config will be created.") + existing_config = {} # Reset if corrupt except Exception as e: print(f"Error reading or backing up {config_path}: {e}") - # existing_config podría estar parcialmente cargado o vacío. - # Continuar para intentar fusionar con defaults. + # existing_config could be partially loaded or empty. + # Continue to try merging with defaults. + # Usamos una copia de existing_config para deep_update si no queremos modificarlo directamente # o asegurarse que deep_update no lo haga si no es deseado. @@ -116,18 +98,22 @@ def ensure_matugen_config(): existing_config, expected_config ) # existing_config se modifica in-place + try: with open(config_path, "w") as f: toml.dump(merged_config, f) except Exception as e: print(f"Error writing matugen config to {config_path}: {e}") + + #generate the stuff only if any of these doesnt exist current_wall = os.path.expanduser("~/.current.wall") hypr_colors = os.path.expanduser( f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf" ) css_colors = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/styles/colors.css") + if ( not os.path.exists(current_wall) or not os.path.exists(hypr_colors) @@ -143,10 +129,12 @@ def ensure_matugen_config(): ) if os.path.exists(example_wallpaper_path): try: + # Si ya existe (posiblemente un enlace roto o archivo regular), eliminar y re-enlazar if os.path.lexists( current_wall ): # lexists para no seguir el enlace si es uno + os.remove(current_wall) os.symlink(example_wallpaper_path, current_wall) image_path = example_wallpaper_path @@ -160,6 +148,7 @@ def ensure_matugen_config(): ) if image_path and os.path.exists(image_path): + print(f"Generating color theme from wallpaper: {image_path}") try: matugen_cmd = f"matugen image '{image_path}'" @@ -178,7 +167,6 @@ def ensure_matugen_config(): f"Warning: Wallpaper at {image_path} not found. Cannot generate matugen theme." ) - def load_bind_vars(): """ Load saved key binding variables from JSON, if available. diff --git a/install.sh b/install.sh index b9682030..73eac69d 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,15 @@ #!/bin/bash -set -e # Exit immediately if a command fails -set -u # Treat unset variables as errors -set -o pipefail # Prevent errors in a pipeline from being masked +set -e # Exit immediately if a command fails +set -u # Treat unset variables as errors +set -o pipefail # Prevent errors in a pipeline from being masked -REPO_URL="https://github.com/Axenide/Ax-Shell.git" +# --- CONFIGURATION --- +REPO_URL="https://github.com/xNovyz/Ax-Shell.git" +REPO_BRANCH="animated-wallpaper" INSTALL_DIR="$HOME/.config/Ax-Shell" +# ---------------------- + PACKAGES=( brightnessctl cava @@ -48,76 +52,91 @@ PACKAGES=( vte3 webp-pixbuf-loader wl-clipboard + mpvpaper ) # Prevent running as root if [ "$(id -u)" -eq 0 ]; then - echo "Please do not run this script as root." - exit 1 + echo "Please do not run this script as root." + exit 1 fi +# Detect AUR helper aur_helper="yay" - -# Check if paru exists, otherwise use yay if command -v paru &>/dev/null; then - aur_helper="paru" + aur_helper="paru" elif ! command -v yay &>/dev/null; then - echo "Installing yay-bin..." - tmpdir=$(mktemp -d) - git clone --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" - (cd "$tmpdir/yay-bin" && makepkg -si --noconfirm) - rm -rf "$tmpdir" + echo "Installing yay-bin..." + tmpdir=$(mktemp -d) + git clone --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" + (cd "$tmpdir/yay-bin" && makepkg -si --noconfirm) + rm -rf "$tmpdir" fi +# Clean if repo exists but is invalid or points to the wrong origin +if git -C "$INSTALL_DIR" rev-parse --is-inside-work-tree &>/dev/null; then + CURRENT_REMOTE=$(git -C "$INSTALL_DIR" remote get-url origin || echo "") + if [ "$CURRENT_REMOTE" != "$REPO_URL" ]; then + echo "Removing old repository from $INSTALL_DIR..." + rm -rf "$INSTALL_DIR" + fi +elif [ -d "$INSTALL_DIR" ]; then + echo "$INSTALL_DIR exists but is not a valid git repository. Removing it..." + rm -rf "$INSTALL_DIR" +fi + + # Clone or update the repository if [ -d "$INSTALL_DIR" ]; then - echo "Updating Ax-Shell..." - git -C "$INSTALL_DIR" pull + echo "Updating Ax-Shell..." + git -C "$INSTALL_DIR" pull + git -C "$INSTALL_DIR" checkout "$REPO_BRANCH" else - echo "Cloning Ax-Shell..." - git clone --depth=1 "$REPO_URL" "$INSTALL_DIR" + echo "Cloning Ax-Shell from $REPO_URL..." + git clone --depth=1 --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR" fi -# Install required packages using the detected AUR helper (only if missing) +# Install required packages echo "Installing required packages..." $aur_helper -Syy --needed --devel --noconfirm "${PACKAGES[@]}" || true echo "Installing gray-git..." yes | $aur_helper -Syy --needed --devel --noconfirm gray-git || true +# Install fonts echo "Installing required fonts..." FONT_URL="https://github.com/zed-industries/zed-fonts/releases/download/1.2.0/zed-sans-1.2.0.zip" FONT_DIR="$HOME/.fonts/zed-sans" TEMP_ZIP="/tmp/zed-sans-1.2.0.zip" -# Check if fonts are already installed if [ ! -d "$FONT_DIR" ]; then - echo "Downloading fonts from $FONT_URL..." - curl -L -o "$TEMP_ZIP" "$FONT_URL" + echo "Downloading fonts from $FONT_URL..." + curl -L -o "$TEMP_ZIP" "$FONT_URL" - echo "Extracting fonts to $FONT_DIR..." - mkdir -p "$FONT_DIR" - unzip -o "$TEMP_ZIP" -d "$FONT_DIR" + echo "Extracting fonts to $FONT_DIR..." + mkdir -p "$FONT_DIR" + unzip -o "$TEMP_ZIP" -d "$FONT_DIR" - echo "Cleaning up..." - rm "$TEMP_ZIP" + echo "Cleaning up..." + rm "$TEMP_ZIP" else - echo "Fonts are already installed. Skipping download and extraction." + echo "Fonts are already installed. Skipping download and extraction." fi -# Copy local fonts if not already present if [ ! -d "$HOME/.fonts/tabler-icons" ]; then - echo "Copying local fonts to $HOME/.fonts/tabler-icons..." - mkdir -p "$HOME/.fonts/tabler-icons" - cp -r "$INSTALL_DIR/assets/fonts/"* "$HOME/.fonts" + echo "Copying local fonts to $HOME/.fonts/tabler-icons..." + mkdir -p "$HOME/.fonts/tabler-icons" + cp -r "$INSTALL_DIR/assets/fonts/"* "$HOME/.fonts" else - echo "Local fonts are already installed. Skipping copy." + echo "Local fonts are already installed. Skipping copy." fi +# Start Ax-Shell python "$INSTALL_DIR/config/config.py" echo "Starting Ax-Shell..." killall ax-shell 2>/dev/null || true -uwsm app -- python "$INSTALL_DIR/main.py" > /dev/null 2>&1 & disown +uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & +disown echo "Installation complete." diff --git a/modules/wallpapers.py b/modules/wallpapers.py index 1a24398c..da27fa0a 100644 --- a/modules/wallpapers.py +++ b/modules/wallpapers.py @@ -2,8 +2,9 @@ import concurrent.futures import hashlib import os -import random # <--- AÑADIDO import shutil +import subprocess +import random # <--- AÑADIDO from concurrent.futures import ThreadPoolExecutor from fabric.utils.helpers import exec_shell_command_async @@ -20,6 +21,32 @@ import modules.icons as icons + +def kill_swww_daemon(): + try: + subprocess.run( + ["pkill", "-x", "swww"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print("Killed swww-daemon if it was running.") + except Exception as e: + print(f"Failed to kill swww: {e}") + +def kill_mpvpaper(): + try: + subprocess.run( + ["pkill", "-x", "mpvpaper"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print("Killed mpvpaper if it was running.") + except Exception as e: + print(f"Failed to kill mpvpaper: {e}") + + class WallpaperSelector(Box): CACHE_DIR = f"{data.CACHE_DIR}/thumbs" # Changed from wallpapers to thumbs @@ -37,7 +64,7 @@ def __init__(self, **kwargs): # and with hyphens instead of spaces) with os.scandir(data.WALLPAPERS_DIR) as entries: for entry in entries: - if entry.is_file() and self._is_image(entry.name): + if entry.is_file() and self._is_media(entry.name): # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces if entry.name != entry.name.lower() or " " in entry.name: new_name = entry.name.lower().replace(" ", "-") @@ -50,7 +77,7 @@ def __init__(self, **kwargs): print(f"Error renaming file {full_path}: {e}") # Refresh the file list after potential renaming - self.files = sorted([f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]) + self.files = sorted([f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_media(f)]) self.thumbnails = [] self.thumbnail_queue = [] self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor @@ -196,6 +223,8 @@ def __init__(self, **kwargs): self.connect("map", self.on_map) self.setup_file_monitor() self.show_all() + self.search_entry.grab_focus() + self.randomize_dice_icon() # Ensure the search entry gets focus when starting self.search_entry.grab_focus() @@ -214,34 +243,55 @@ def randomize_dice_icon(self): if isinstance(label, Label): label.set_markup(chosen_icon) - def set_random_wallpaper(self, widget, external=False): - if not self.files: - print("No wallpapers available to set a random one.") - return - - file_name = random.choice(self.files) + def apply_wallpaper(self, file_name, external=False): full_path = os.path.join(data.WALLPAPERS_DIR, file_name) selected_scheme = self.scheme_dropdown.get_active_id() - current_wall = os.path.expanduser(f"~/.current.wall") + current_wall = os.path.expanduser("~/.current.wall") - if os.path.isfile(current_wall) or os.path.islink(current_wall): # Check for link too - os.remove(current_wall) - os.symlink(full_path, current_wall) + # Kill both wallpaper daemons before switching (for both image and video) + kill_swww_daemon() + kill_mpvpaper() - if self.matugen_switcher.get_active(): - exec_shell_command_async(f'matugen image "{full_path}" -t {selected_scheme}') - else: + if self._is_image(file_name): + # Set symlink for images + if os.path.islink(current_wall) or os.path.isfile(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) exec_shell_command_async( f'swww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' ) - - print(f"Set random wallpaper: {file_name}") + if self.matugen_switcher.get_active(): + exec_shell_command_async(f'matugen image "{full_path}" -t {selected_scheme}') + elif self._is_video(file_name): + exec_shell_command_async(f'mpvpaper ALL "{full_path}" -o "--loop-file=inf --no-audio --no-osd-bar --osc=no --input-default-bindings=no --no-osc --no-input-default-bindings"') + extracted_frame = self._extract_video_frame(full_path, size="1920:-1") + if extracted_frame: + # update .current.wall to the extracted frame for Hyprlock + if os.path.islink(current_wall) or os.path.isfile(current_wall): + os.remove(current_wall) + os.symlink(extracted_frame, current_wall) + if self.matugen_switcher.get_active(): + exec_shell_command_async(f'matugen image "{extracted_frame}" -t {selected_scheme}') + else: + print(f"Unsupported wallpaper type: {file_name}") + return + + print(f"Set wallpaper: {file_name}") if external: - exec_shell_command_async(f"notify-send '🎲 Wallpaper' 'Setting a random wallpaper 🎨' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e") + exec_shell_command_async( + f"notify-send '🎲 Wallpaper' 'Setting wallpaper 🎨' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e" + ) self.randomize_dice_icon() + def set_random_wallpaper(self, widget, external=False): + if not self.files: + print("No wallpapers available to set a random one.") + return + file_name = random.choice(self.files) + self.apply_wallpaper(file_name, external=external) + def setup_file_monitor(self): gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR) self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) @@ -261,7 +311,7 @@ def on_directory_changed(self, monitor, file, other_file, event_type): self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) elif event_type == Gio.FileMonitorEvent.CREATED: - if self._is_image(file_name): + if self._is_media(file_name): # Convert filename to lowercase and replace spaces with "-" new_name = file_name.lower().replace(" ", "-") full_path = os.path.join(data.WALLPAPERS_DIR, file_name) @@ -278,7 +328,7 @@ def on_directory_changed(self, monitor, file, other_file, event_type): self.files.sort() self.executor.submit(self._process_file, file_name) elif event_type == Gio.FileMonitorEvent.CHANGED: - if self._is_image(file_name) and file_name in self.files: + if self._is_media(file_name) and file_name in self.files: cache_path = self._get_cache_path(file_name) if os.path.exists(cache_path): try: @@ -308,20 +358,31 @@ def arrange_viewport(self, query: str = ""): def on_wallpaper_selected(self, iconview, path): model = iconview.get_model() file_name = model[path][1] - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - selected_scheme = self.scheme_dropdown.get_active_id() - current_wall = os.path.expanduser(f"~/.current.wall") - if os.path.isfile(current_wall) or os.path.islink(current_wall): - os.remove(current_wall) - os.symlink(full_path, current_wall) - if self.matugen_switcher.get_active(): - # Matugen is enabled: run the normal command. - exec_shell_command_async(f'matugen image "{full_path}" -t {selected_scheme}') - else: - # Matugen is disabled: run the alternative swww command. - exec_shell_command_async( - f'swww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + self.apply_wallpaper(file_name) + + def _extract_video_frame(self, video_path, size="1920:-1"): + frame_name = hashlib.md5(video_path.encode("utf-8")).hexdigest() + f"_frame_{size}.png" + frame_path = os.path.join(self.CACHE_DIR, frame_name) + try: + subprocess.run( + [ + "ffmpeg", + "-y", + "-ss", "0.5", + "-i", video_path, + "-vframes", "1", + "-vf", f"scale={size}", + frame_path + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE ) + if os.path.exists(frame_path): + return frame_path + except Exception as e: + print(f"Error extracting frame from video {video_path}: {e}") + return None def on_scheme_changed(self, combo): selected_scheme = combo.get_active_id() @@ -453,16 +514,32 @@ def _process_file(self, file_name): cache_path = self._get_cache_path(file_name) if not os.path.exists(cache_path): try: - with Image.open(full_path) as img: - width, height = img.size - side = min(width, height) - left = (img.width - side) // 2 - top = (height - side) // 2 - right = left + side - bottom = top + side - img_cropped = img.crop((left, top, right, bottom)) - img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) - img_cropped.save(cache_path, "PNG") + if self._is_image(file_name): + with Image.open(full_path) as img: + width, height = img.size + side = min(width, height) + left = (img.width - side) // 2 + top = (height - side) // 2 + right = left + side + bottom = top + side + img_cropped = img.crop((left, top, right, bottom)) + img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) + img_cropped.save(cache_path, "PNG") + elif self._is_video(file_name): + subprocess.run( + [ + "ffmpeg", + "-y", + "-ss", "0.5", + "-i", full_path, + "-vframes", "1", + "-vf", "scale=96:96:force_original_aspect_ratio=decrease", + cache_path + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) except Exception as e: print(f"Error processing {file_name}: {e}") return @@ -491,14 +568,20 @@ def _get_cache_path(self, file_name: str) -> str: def _is_image(file_name: str) -> bool: return file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp')) + @staticmethod + def _is_video(file_name: str) -> bool: + return file_name.lower().endswith('.mp4') + + @classmethod + def _is_media(cls, file_name: str) -> bool: + return cls._is_image(file_name) or cls._is_video(file_name) + def on_search_entry_focus_out(self, widget, event): if self.get_mapped(): widget.grab_focus() return False def on_map(self, widget): - """Handles the map signal to set initial visibility of the color selector.""" - # Set visibility based on the loaded state when the widget becomes visible self.custom_color_selector_box.set_visible(not self.matugen_enabled) def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: