diff --git a/.config/.DS_Store b/.config/.DS_Store
deleted file mode 100644
index 171b844..0000000
Binary files a/.config/.DS_Store and /dev/null differ
diff --git a/.config/alacritty/alacritty.toml b/.config/alacritty/alacritty.toml
old mode 100644
new mode 100755
index 17232a1..e25c650
--- a/.config/alacritty/alacritty.toml
+++ b/.config/alacritty/alacritty.toml
@@ -12,11 +12,11 @@ opacity = 0.6
blur = true
option_as_alt = "Both"
[font]
-size = 17.5
-normal.family = "DepartureMono Nerd Font Mono"
+size = 14.5
+# normal.family = "DepartureMono Nerd Font Mono"
[selection]
save_to_clipboard = true
[mouse]
hide_when_typing = true
-[terminal]
-shell ={ program = "zsh", args = ["-l", "-c", "tmux attach -t Dev || tmux new -s Dev"]}
+# [terminal]
+# shell ={ program = "zsh", args = ["-l", "-c", "tmux attach -t Dev || tmux new -s Dev"]}
diff --git a/.config/discord-rpc/config.toml b/.config/discord-rpc/config.toml
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/beautyful.png b/.config/fastfetch/beautyful.png
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/chainsaw_man___denji_manga.png b/.config/fastfetch/chainsaw_man___denji_manga.png
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/config.jsonc b/.config/fastfetch/config.jsonc
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/logo.png b/.config/fastfetch/logo.png
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/logo1.PNG b/.config/fastfetch/logo1.PNG
old mode 100644
new mode 100755
diff --git a/.config/fastfetch/logo3.png b/.config/fastfetch/logo3.png
old mode 100644
new mode 100755
diff --git a/.config/fish/config.bk.fish b/.config/fish/config.bk.fish
old mode 100644
new mode 100755
diff --git a/.config/fontconfig/fonts.conf b/.config/fontconfig/fonts.conf
new file mode 100644
index 0000000..1dd7e34
--- /dev/null
+++ b/.config/fontconfig/fonts.conf
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Arial
+
+ Noto Sans
+ Noto Naskh Arabic
+ Amiri
+
+
+
+ sans-serif
+
+ Noto Sans
+ Noto Naskh Arabic
+ Noto Sans Arabic
+
+
+
+ serif
+
+ Noto Serif
+ Noto Naskh Arabic
+ Amiri
+
+
+
+ monospace
+
+ Noto Sans Mono
+ Noto Naskh Arabic
+
+
+
diff --git a/.config/gh-dash/config.yml b/.config/gh-dash/config.yml
old mode 100644
new mode 100755
index 0e754e0..537b6f1
--- a/.config/gh-dash/config.yml
+++ b/.config/gh-dash/config.yml
@@ -86,7 +86,7 @@ keybindings:
- key: C
name: code review
command: >
- tmux new-window -n "PR-{{.PrNumber}}"
+ herdr pane split --direction right --current --env "PR-{{.PrNumber}}"
'wt switch pr:{{.PrNumber}} -x "opencode --prompt `@code-reviewer: review-pr-for-this-branch`"'
theme:
colors:
@@ -110,7 +110,7 @@ theme:
compact: false
repoPaths: {}
pager:
- diff: ""
+ diff: "hunk pager"
confirmQuit: false
showAuthorIcons: true
smartFilteringAtLaunch: true
diff --git a/.config/gh/config.yml b/.config/gh/config.yml
old mode 100644
new mode 100755
diff --git a/.config/gh/hosts.yml b/.config/gh/hosts.yml
old mode 100644
new mode 100755
diff --git a/.config/ghostty/config b/.config/ghostty/config
old mode 100644
new mode 100755
index df0f8ce..c35b244
--- a/.config/ghostty/config
+++ b/.config/ghostty/config
@@ -1,12 +1,12 @@
# vim: set ft=bash:
font-family = "DepartureMono Nerd Font Mono"
-font-size = 16
+font-size = 13
font-thicken = true
-background-opacity = 0.7
-background-blur-radius = 27
+# background-opacity = 0.7
+# background-blur-radius = 27
# themes i Like [Banana Blueberry,Everforest Dark - Hard,Batman,CrayonPonyFish,duckbones,Builtin Tango Dark,embers-dark,CGA,Earthsong,Espresso Libre, Adwaita Dark, 3024 Night, 0x96f, Adventure, flexoki-dark, Monokai Pro Ristretto, embers-dark, Solarized Dark Higher Contrast]
-theme = Black Metal
-font-thicken = true
+# theme = Black Metal
+# font-thicken = true
cursor-text = white
window-save-state = always
cursor-style = block
@@ -22,6 +22,6 @@ shell-integration-features = true
window-theme = ghostty
auto-update = check
window-padding-balance = true
-adjust-cell-height = 50%
+# adjust-cell-height = 50%
keybind = cmd+s=toggle_quick_terminal
keybind = super+i=inspector:toggle
diff --git a/.config/ghostty/themes/forest-night-ghostty.theme b/.config/ghostty/themes/forest-night-ghostty.theme
old mode 100644
new mode 100755
diff --git a/.config/hammerflow/config.toml b/.config/hammerflow/config.toml
old mode 100644
new mode 100755
diff --git a/.config/herdr/config.toml b/.config/herdr/config.toml
new file mode 100644
index 0000000..27d35fe
--- /dev/null
+++ b/.config/herdr/config.toml
@@ -0,0 +1,124 @@
+onboarding = false
+
+[ui.toast]
+delivery = "terminal"
+
+[ui]
+show_agent_labels_on_pane_borders = false
+sidebar_hidden_width = 0 # fully hide sidebar (no thin strip)
+hide_tab_bar_when_single_tab = true # hide tab bar at 1 tab
+hide_pane_borders_when_zoomed = true
+# pane_borders = "minimal"
+
+[theme]
+name = "gruvbox"
+
+# [keys]
+# detach = "prefix+q"
+# workspace_picker = "prefix+w"
+# goto = "prefix+g"
+# new_workspace = "prefix+shift+n"
+# new_worktree = "prefix+shift+g"
+# rename_workspace = "prefix+shift+w"
+# close_workspace = "prefix+shift+d"
+# navigate_workspace_up = "up"
+# navigate_workspace_down = "down"
+# navigate_pane_left = "h"
+# navigate_pane_down = "j"
+# navigate_pane_up = "k"
+# navigate_pane_right = "l"
+# remote_image_paste = "ctrl+v"
+# new_tab = "prefix+c"
+# previous_tab = "prefix+p"
+# next_tab = "prefix+n"
+# switch_tab = "prefix+1..9"
+# rename_tab = "prefix+shift+t"
+# close_tab = "prefix+shift+x"
+# copy_mode = "prefix+["
+# focus_pane_left = "prefix+h"
+# focus_pane_down = "prefix+j"
+# focus_pane_up = "prefix+k"
+# focus_pane_right = "prefix+l"
+# swap_pane_left = "prefix+shift+h"
+# swap_pane_down = "prefix+shift+j"
+# swap_pane_up = "prefix+shift+k"
+# swap_pane_right = "prefix+shift+l"
+# cycle_pane_next = "prefix+tab"
+# cycle_pane_previous = "prefix+shift+tab"
+# last_pane = ""
+# split_vertical = "prefix+v"
+# split_horizontal = "prefix+minus"
+# close_pane = "prefix+x"
+# zoom = "prefix+z"
+# resize_mode = "prefix+r"
+# toggle_sidebar = "prefix+b"
+
+[keys]
+last_pane = "prefix+,"
+focus_pane_left = "prefix+h"
+focus_pane_down = "prefix+j"
+focus_pane_up = "prefix+k"
+focus_pane_right = "prefix+l"
+reload_config = "prefix+r"
+resize_mode = "prefix+R"
+# goto = "prefix+s"
+navigate_workspace_up = "ctrl+k"
+navigate_workspace_down = "ctrl+j"
+zoom = "prefix+m"
+previous_workspace = "prefix+ctrl+k"
+# next_workspace = "prefix+ctrl+j"
+focus_agent = "prefix+ctrl+1..9"
+# switch_workspace = "prefix+shift+1..9"
+prefix = "ctrl+a"
+# split_vertical = "prefix+|"
+
+[[keys.command]]
+key = "prefix+ctrl+q"
+type = "pane"
+command = "btop"
+description = "run btop"
+
+[[keys.command]]
+key = "prefix+ctrl+j"
+type = "pane"
+command = "jjui"
+description = "run jjui"
+
+[[keys.command]]
+key = "prefix+ctrl+l"
+type = "pane"
+command = "lazydocker"
+description = "run lazydocker"
+
+[[keys.command]]
+key = "prefix+ctrl+g"
+type = "shell"
+command = "zsh -i -c github_open"
+description = "run github_open"
+
+[[keys.command]]
+key = "prefix+f"
+type = "pane"
+command = "zsh -i -c tmux-sessionizer"
+description = "run herdr_sessionizer"
+
+[experimental]
+kitty_graphics = true
+
+[[keys.command]]
+key = "prefix+a"
+type = "plugin_action"
+command = "nathanflurry.jj-workspace.new"
+description = "new jj workspace"
+
+[[keys.command]]
+key = "prefix+shift+a"
+type = "plugin_action"
+command = "nathanflurry.jj-workspace.new-tab"
+description = "new jj workspace (in tab)"
+
+[[keys.command]]
+key = "prefix+d"
+type = "plugin_action"
+command = "nathanflurry.jj-workspace.remove"
+description = "remove jj workspace"
diff --git a/.config/herdr/release-notes.json b/.config/herdr/release-notes.json
new file mode 100644
index 0000000..e399068
--- /dev/null
+++ b/.config/herdr/release-notes.json
@@ -0,0 +1,5 @@
+{
+ "version": "0.7.1",
+ "body": "### Added\n- Added `[update].version_check` and `[update].manifest_check` so background Herdr version checks and remote agent-detection manifest checks can be disabled independently. Manual `herdr update` and bundled/local detection manifests still work when the background checks are disabled. (#677)\n- Added `HERDR_AGENT=` as a Linux foreground-process hint for agents hidden behind wrappers such as VMs, Bubblewrap, or `fence`, allowing Herdr to use the named agent's screen manifest when `/proc` cannot expose the real command. (#679)\n- Added `ui.pane_borders` and `ui.pane_gaps` to make split pane dividers and spacing configurable. (#271)\n\n### Changed\n- Removed the Agents panel workspace/all filter. The panel now always shows all agents, defaults to grouped-by-space ordering, and can switch to priority ordering with `ui.agent_panel_sort = \"priority\"`. (#318)\n- User keybindings now displace conflicting built-in defaults during config load, so overriding a default binding no longer leaves both actions attached to the same key. (#747)\n- Worktree creation now checks out an existing local branch when the requested branch already exists instead of failing by trying to create it again. (#729)\n- Worktree operations started through the socket API and plugin/UI flows now defer long-running Git work until the app runtime can drive it, keeping clients responsive and preserving plugin lifecycle events for worktree-created panes. (#657, #662, #686)\n- OMP, OpenCode, Pi, Devin, and other official hook integrations now scope lifecycle and session reports to the intended root agent process more reliably, reducing stale or cross-process session adoption after restarts, nested commands, and new sessions. (#614, #712, #719, #765)\n\n### Fixed\n- Windows Terminal multiline text paste now reaches pane apps as one bracketed paste, so OMP, Pi, and similar prompts no longer submit each pasted line separately. Plain Esc, Shift+Enter, mouse, focus, resize, and Unicode paste handling are preserved on the Windows client path. (#670)\n- Local Herdr clients no longer treat raw `Ctrl+V` as a clipboard-image paste trigger, so pane apps such as Vim and Neovim receive block-visual `Ctrl+V` even when the desktop clipboard contains an image. `herdr --remote` keeps `keys.remote_image_paste = \"ctrl+v\"` by default. (#647)\n- Herdr now refreshes cached host terminal colors when terminals report a light/dark color-scheme change, so pane apps that query OSC 10/11 no longer need detach/attach to see updated default colors. Opt-in `[theme].auto_switch` can also switch Herdr's own UI between configured `dark_name` and `light_name` themes. (#675)\n- Full-lifecycle hook agents can now recover when an old release/report sequence belongs to a previous agent generation. Herdr keeps process-exit validation active under lifecycle authority and re-anchors hook sequence guards after fresh session references or proven process exits. (#684)\n- OMP now reports a native session reference, so an OMP pane reappears in the Agents panel after exiting and rerunning `omp` in the same pane, and Herdr can resume it with `omp --resume=`. Previously the released lifecycle hook stayed suppressed until a server restart. (#614)\n- Host terminal color query (OSC 10/11) replies that arrive split at their escape introducer no longer leak as text like `11;rgb:...` into the focused pane, most visible when launching agents that probe terminal colors on startup. (#549)\n- Long CJK Git branch names in the sidebar now truncate by display width instead of overflowing or cutting at the wrong cell boundary. (#644)\n- Temporary pane commands launched from API flows no longer steal focus from the previously focused pane after they finish. (#658)\n- Root agent session restore now ignores child process reports that would otherwise overwrite the saved session for the owning pane. (#712)\n- Kitty file-transfer media queries are now answered, allowing pane apps that rely on kitty graphics file support to detect image/file media capability correctly. (#732)\n- Idle or slow clients no longer block server writes to other clients while the blocked client is waiting for output. (#726)\n- GitHub Copilot CLI `ask_user` accept prompts are now detected as blocked so the Agents panel shows that the pane is waiting for input. (#725)\n- Pane reads now skip wide-character spacer cells, avoiding duplicated or malformed output around double-width characters. (#698)\n- Split pane border intersections now use the active pane color consistently. (#742, thanks @cullendotdev)\n- The Windows installer checksum fallback no longer depends on `Get-FileHash`, improving compatibility with constrained PowerShell environments. (#751)\n- Pi launched through npm wrappers on Windows is now detected as Pi instead of a generic wrapped process. (#754)\n- Windows builds now force the system ConPTY path through a vendored `portable-pty` patch, avoiding the bundled-path startup failure seen in affected Windows environments. (#761)\n- Key release events that fall back to encoded input no longer double-send text into pane apps. (#769)\n- Remote clients now allow a longer initial handshake, improving `herdr --remote` startup over high-latency links. (#753)",
+ "show_on_startup": true
+}
\ No newline at end of file
diff --git a/.config/herdr/sessions/Dev/plugins.json b/.config/herdr/sessions/Dev/plugins.json
new file mode 100644
index 0000000..6411d4b
--- /dev/null
+++ b/.config/herdr/sessions/Dev/plugins.json
@@ -0,0 +1,132 @@
+[
+ {
+ "plugin_id": "url.fzf-picker",
+ "name": "FZF URL Picker",
+ "version": "0.1.0",
+ "min_herdr_version": "0.7.0",
+ "description": "Scan terminal panes for URLs and interactively pick one with fzf",
+ "manifest_path": "/home/dox/Developer/hurl/herdr-fzf-url/herdr-plugin.toml",
+ "plugin_root": "/home/dox/Developer/hurl/herdr-fzf-url",
+ "enabled": true,
+ "platforms": [
+ "linux",
+ "macos"
+ ],
+ "build": [
+ {
+ "command": [
+ "go",
+ "build",
+ "-o",
+ "herdr-fzf-url"
+ ]
+ }
+ ],
+ "actions": [
+ {
+ "id": "pick-url",
+ "title": "Pick URL from Panes",
+ "contexts": [
+ "pane"
+ ],
+ "command": [
+ "./herdr-fzf-url"
+ ]
+ }
+ ],
+ "panes": [
+ {
+ "id": "url-picker",
+ "title": "URL Picker",
+ "placement": "overlay",
+ "command": [
+ "./herdr-fzf-url"
+ ]
+ }
+ ],
+ "source": {
+ "kind": "local"
+ }
+ },
+ {
+ "plugin_id": "nathanflurry.jj-workspace",
+ "name": "jj workspaces",
+ "version": "0.1.0",
+ "min_herdr_version": "0.7.0",
+ "description": "Create and remove Jujutsu (jj) workspaces as Herdr workspaces or tabs.",
+ "manifest_path": "/home/dox/.config/herdr/sessions/Dev/plugins/github/nathanflurry.jj-workspace-19f0b858b2b4/herdr-plugin.toml",
+ "plugin_root": "/home/dox/.config/herdr/sessions/Dev/plugins/github/nathanflurry.jj-workspace-19f0b858b2b4",
+ "enabled": true,
+ "platforms": [
+ "linux",
+ "macos"
+ ],
+ "build": [
+ {
+ "command": [
+ "cargo",
+ "build",
+ "--release"
+ ]
+ }
+ ],
+ "actions": [
+ {
+ "id": "new",
+ "title": "New jj workspace",
+ "contexts": [
+ "workspace",
+ "global"
+ ],
+ "command": [
+ "./target/release/jj-workspace",
+ "open",
+ "workspace"
+ ]
+ },
+ {
+ "id": "new-tab",
+ "title": "New jj workspace (in tab)",
+ "contexts": [
+ "workspace",
+ "global"
+ ],
+ "command": [
+ "./target/release/jj-workspace",
+ "open",
+ "tab"
+ ]
+ },
+ {
+ "id": "remove",
+ "title": "Remove jj workspace",
+ "contexts": [
+ "workspace"
+ ],
+ "command": [
+ "./target/release/jj-workspace",
+ "remove"
+ ]
+ }
+ ],
+ "panes": [
+ {
+ "id": "wizard",
+ "title": "New jj workspace",
+ "placement": "overlay",
+ "command": [
+ "./target/release/jj-workspace",
+ "wizard"
+ ]
+ }
+ ],
+ "source": {
+ "kind": "github",
+ "owner": "NathanFlurry",
+ "repo": "herdr-plugin-jj-workspace",
+ "resolved_commit": "29650eaea69795dd4414be26b434e0fd43a59463",
+ "managed_path": "/home/dox/.config/herdr/sessions/Dev/plugins/github/nathanflurry.jj-workspace-19f0b858b2b4",
+ "installed_unix_ms": 1782333322865
+ }
+ }
+]
\ No newline at end of file
diff --git a/.config/jj/config.toml b/.config/jj/config.toml
old mode 100644
new mode 100755
index df855ab..48265ad
--- a/.config/jj/config.toml
+++ b/.config/jj/config.toml
@@ -1,9 +1,13 @@
"$schema" = "https://jj-vcs.github.io/jj/latest/config-schema.json"
+[ui]
+gitignore = "/home/dox/.config/jj/gitignore"
+pager = ["hunk", "pager"]
+diff-formatter = ":git"
+
[user]
name = "x0d7x"
email = "75843278+x0d7x@users.noreply.github.com"
-
[signing]
backend = "gpg"
key = "672910B4BB401394"
diff --git a/.config/jj/gitignore b/.config/jj/gitignore
new file mode 100644
index 0000000..89c7307
--- /dev/null
+++ b/.config/jj/gitignore
@@ -0,0 +1,3 @@
+.build/
+__pycache__/
+*.pyc
diff --git a/.config/jj/repos/0170aba46128f45a282f/config.toml b/.config/jj/repos/0170aba46128f45a282f/config.toml
new file mode 100644
index 0000000..3bc0a48
--- /dev/null
+++ b/.config/jj/repos/0170aba46128f45a282f/config.toml
@@ -0,0 +1,7 @@
+"$schema" = "https://jj-vcs.github.io/jj/latest/config-schema.json"
+
+[revset-aliases]
+"trunk()" = "master@origin"
+
+[snapshot]
+max-new-file-size = 2362212
diff --git a/.config/jj/repos/0170aba46128f45a282f/metadata.binpb b/.config/jj/repos/0170aba46128f45a282f/metadata.binpb
new file mode 100644
index 0000000..4f027b2
--- /dev/null
+++ b/.config/jj/repos/0170aba46128f45a282f/metadata.binpb
@@ -0,0 +1,2 @@
+
+/home/dox/dot/.jj/repo
\ No newline at end of file
diff --git a/.config/jj/repos/2a2e54e6b9fa275d69ce/config.toml b/.config/jj/repos/2a2e54e6b9fa275d69ce/config.toml
new file mode 100644
index 0000000..b137e0c
--- /dev/null
+++ b/.config/jj/repos/2a2e54e6b9fa275d69ce/config.toml
@@ -0,0 +1,3 @@
+#:schema https://docs.jj-vcs.dev/latest/config-schema.json
+
+[revset-aliases]
diff --git a/.config/jj/repos/805178e7fd06e27095b5/config.toml b/.config/jj/repos/805178e7fd06e27095b5/config.toml
new file mode 100644
index 0000000..6b9b9c4
--- /dev/null
+++ b/.config/jj/repos/805178e7fd06e27095b5/config.toml
@@ -0,0 +1,4 @@
+#:schema https://docs.jj-vcs.dev/latest/config-schema.json
+
+[revset-aliases]
+"trunk()" = "master@origin"
diff --git a/.config/jj/repos/b71d36bd0f8946a7eaaf/config.toml b/.config/jj/repos/b71d36bd0f8946a7eaaf/config.toml
new file mode 100644
index 0000000..ad3b8fb
--- /dev/null
+++ b/.config/jj/repos/b71d36bd0f8946a7eaaf/config.toml
@@ -0,0 +1,4 @@
+"$schema" = "https://jj-vcs.github.io/jj/latest/config-schema.json"
+
+[revset-aliases]
+"trunk()" = "master@origin"
diff --git a/.config/jj/repos/b71d36bd0f8946a7eaaf/metadata.binpb b/.config/jj/repos/b71d36bd0f8946a7eaaf/metadata.binpb
new file mode 100644
index 0000000..7acd9f3
--- /dev/null
+++ b/.config/jj/repos/b71d36bd0f8946a7eaaf/metadata.binpb
@@ -0,0 +1,2 @@
+
+%/home/dox/.config/nix-config/.jj/repo
\ No newline at end of file
diff --git a/.config/kitty/current-theme.conf b/.config/kitty/current-theme.conf
deleted file mode 100644
index de67b69..0000000
--- a/.config/kitty/current-theme.conf
+++ /dev/null
@@ -1,70 +0,0 @@
-# vim:ft=kitty
-
-## name: Kanagawa_dragon
-## license: MIT
-## author: Tommaso Laurenzi
-## upstream: https://github.com/rebelot/kanagawa.nvim/
-## blurb: NeoVim dark colorscheme inspired by the colors of the famous painting
-## by Katsushika Hokusai. Dragon version.
-
-#: The basic colors
-
-foreground #c5c9c5
-background #181616
-selection_foreground #c8c093
-selection_background #2d4f67
-
-#: Cursor colors
-
-cursor #c8c093
-
-#: URL underline color when overing with mouse
-
-url_color #72a7bc
-
-#: Tab bar colors
-
-active_tab_foreground #c8c093
-active_tab_background #12120f
-inactive_tab_foreground #a6a69c
-inactive_tab_background #12120f
-
-#: The basic 16 colors
-
-#: black
-color0 #0d0c0c
-color8 #a6a69c
-
-#: red
-color1 #c4746e
-color9 #e46876
-
-#: green
-color2 #8a9a7b
-color10 #87a987
-
-#: yellow
-color3 #c4b28a
-color11 #e6c384
-
-#: blue
-color4 #8ba4b0
-color12 #7fb4ca
-
-#: magenta
-color5 #a292a3
-color13 #938aa9
-
-#: cyan
-color6 #8ea4a2
-color14 #7aa89f
-
-#: white
-color7 #c8c093
-color15 #c5c9c5
-
-
-#: You can set the remaining 240 colors as color16 to color255.
-
-color16 #b6927b
-color17 #b98d7b
diff --git a/.config/kitty/current-theme.conf b/.config/kitty/current-theme.conf
new file mode 120000
index 0000000..fa675e2
--- /dev/null
+++ b/.config/kitty/current-theme.conf
@@ -0,0 +1 @@
+themes/noctalia.conf
\ No newline at end of file
diff --git a/.config/kitty/kitty.conf b/.config/kitty/kitty.conf
old mode 100644
new mode 100755
index fb47dc8..6d9e291
--- a/.config/kitty/kitty.conf
+++ b/.config/kitty/kitty.conf
@@ -1,18 +1,18 @@
# vim: set ft=bash:
# BEGIN_KITTY_FONTS
-font_family family="DepartureMono Nerd Font Mono"
+font_family family="EnvyCodeR Nerd Font Mono"
bold_font auto
italic_font auto
bold_italic_font auto
# END_KITTY_FONTS
-font_size 17
-# modify_font cell_width 95%
+font_size 14
+modify_font cell_width 95%
modify_font cell_height 1.25
macos_thicken_font 0.8
text_composition_strategy legacy
# # Automatically start tmux if not inside it
# shell /run/current-system/sw/bin/zsh -l -c "if command -v tmux &>/dev/null && [ -z \"\$TMUX\" ] && [ ! -f /tmp/kitty_tmux_started_\$USER ]; then touch /tmp/kitty_tmux_started_\$USER; tmux attach || tmux new -s Dev; else exec zsh; fi"
-shell zsh -l -c '[ -z "$TMUX" ] && tmux attach -t Dev || tmux new -s Dev || zsh'
+shell zsh -l -c '[ -z "$HERDR_SESSION" ] && herdr --session Dev || zsh'
# shell fish -l -c 'test -z "$TMUX" && tmux attach -t Dev 2>/dev/null || tmux new -s Dev || fish'
enable_audio_bell yes
notify_on_cmd_finish unfocused
@@ -23,13 +23,13 @@ cursor_shape block
cursor_blink_interval 0.5 ease-in-out
macos_colorspace displayp3
macos_option_as_alt yes
-background_blur 30
-background_opacity 0.7
+# background_blur 30
+# background_opacity 0.7
confirm_os_window_close 0
hide_window_decorations titlebar-only
mouse_hide_wait -3.0
window_padding_width 0
# BEGIN_KITTY_THEME
-# Kanagawa_dragon
+# Noctalia
include current-theme.conf
# END_KITTY_THEME
diff --git a/.config/kitty/kitty.conf.bak b/.config/kitty/kitty.conf.bak
new file mode 100644
index 0000000..c4ad4be
--- /dev/null
+++ b/.config/kitty/kitty.conf.bak
@@ -0,0 +1,35 @@
+# vim: set ft=bash:
+# BEGIN_KITTY_FONTS
+font_family family="DepartureMono Nerd Font Mono"
+bold_font auto
+italic_font auto
+bold_italic_font auto
+# END_KITTY_FONTS
+font_size 13
+# modify_font cell_width 95%
+modify_font cell_height 1.25
+macos_thicken_font 0.8
+text_composition_strategy legacy
+# # Automatically start tmux if not inside it
+# shell /run/current-system/sw/bin/zsh -l -c "if command -v tmux &>/dev/null && [ -z \"\$TMUX\" ] && [ ! -f /tmp/kitty_tmux_started_\$USER ]; then touch /tmp/kitty_tmux_started_\$USER; tmux attach || tmux new -s Dev; else exec zsh; fi"
+shell zsh -l -c '[ -z "$TMUX" ] && tmux attach -t Dev || tmux new -s Dev || zsh'
+# shell fish -l -c 'test -z "$TMUX" && tmux attach -t Dev 2>/dev/null || tmux new -s Dev || fish'
+enable_audio_bell yes
+notify_on_cmd_finish unfocused
+cursor_trail 12
+cursor_trail_start_threshold 0
+cursor_trail_decay 0.04 0.30
+cursor_shape block
+cursor_blink_interval 0.5 ease-in-out
+macos_colorspace displayp3
+macos_option_as_alt yes
+# background_blur 30
+# background_opacity 0.7
+confirm_os_window_close 0
+hide_window_decorations titlebar-only
+mouse_hide_wait -3.0
+window_padding_width 0
+# BEGIN_KITTY_THEME
+# Kanagawa_dragon
+include current-theme.conf
+# END_KITTY_THEME
diff --git a/.config/kitty/themes/noctalia.conf b/.config/kitty/themes/noctalia.conf
new file mode 100644
index 0000000..e5955e1
--- /dev/null
+++ b/.config/kitty/themes/noctalia.conf
@@ -0,0 +1,32 @@
+color0 #101413
+color1 #ffb4ab
+color2 #55dbc7
+color3 #b1ccc6
+color4 #accae5
+color5 #55dbc7
+color6 #b1ccc6
+color7 #e0e3e1
+color8 #899390
+color9 #ffb4ab
+color10 #55dbc7
+color11 #b1ccc6
+color12 #accae5
+color13 #55dbc7
+color14 #b1ccc6
+color15 #e0e3e1
+
+cursor #e0e3e1
+cursor_text_color #101413
+background #101413
+foreground #e0e3e1
+selection_foreground #bec9c5
+selection_background #3f4946
+active_border_color #55dbc7
+inactive_border_color #3f4946
+url_color #55dbc7
+
+active_tab_foreground #003731
+active_tab_background #55dbc7
+inactive_tab_foreground #bec9c5
+inactive_tab_background #3f4946
+cursor_trail_color #bec9c5
diff --git a/.config/kitty/themes/zentheme.conf b/.config/kitty/themes/zentheme.conf
old mode 100644
new mode 100755
diff --git a/.config/mpd/mpd.conf b/.config/mpd/mpd.conf
old mode 100644
new mode 100755
diff --git a/.config/neomd/config.toml b/.config/neomd/config.toml
new file mode 100644
index 0000000..902e1f7
--- /dev/null
+++ b/.config/neomd/config.toml
@@ -0,0 +1,115 @@
+store_sent_drafts_in_sending_account = false
+auto_bcc = ""
+
+[[accounts]]
+ name = "Personal"
+ imap = "imap.gmail.com:993"
+ smtp = "smtp.gmail.com:587"
+ user = ""
+ password = ""
+ from = ""
+ starttls = false
+ tls_cert_file = ""
+ imap_disabled = false
+ auth_type = ""
+ oauth2_client_id = ""
+ oauth2_client_secret = ""
+ oauth2_issuer_url = ""
+ oauth2_auth_url = ""
+ oauth2_token_url = ""
+ oauth2_redirect_port = 0
+
+[[accounts]]
+ name = "vlog"
+ imap = "imap.gmail.com:993"
+ smtp = "smtp.gmail.com:587"
+ user = ""
+ password = ""
+ from = ""
+ starttls = false
+ tls_cert_file = ""
+ imap_disabled = false
+ auth_type = ""
+ oauth2_client_id = ""
+ oauth2_client_secret = ""
+ oauth2_issuer_url = ""
+ oauth2_auth_url = ""
+ oauth2_token_url = ""
+ oauth2_redirect_port = 0
+
+[screener]
+ screened_in = "/home/dox/.config/neomd/lists/screened_in.txt"
+ screened_out = "/home/dox/.config/neomd/lists/screened_out.txt"
+ feed = "/home/dox/.config/neomd/lists/feed.txt"
+ papertrail = "/home/dox/.config/neomd/lists/papertrail.txt"
+ spam = "/home/dox/.config/neomd/lists/spam.txt"
+ notify = "/home/dox/.config/neomd/lists/notify.txt"
+
+[folders]
+inbox = "INBOX"
+sent = "[Gmail]/Sent Mail"
+trash = "[Gmail]/Trash"
+drafts = "[Gmail]/Drafts"
+to_screen = "ToScreen"
+feed = "Feed"
+papertrail = "PaperTrail"
+screened_out = "ScreenedOut"
+archive = "Archive Neomd"
+waiting = "Waiting"
+scheduled = "Scheduled Neomd"
+someday = "Someday"
+spam = "[Gmail]/Spam"
+tab_order = ["inbox", "to_screen", "feed", "papertrail",
+"waiting", "someday", "scheduled", "sent", "archive",
+"screened_out", "trash"]
+
+[ui]
+ theme = "gruvbox"
+ inbox_count = 500
+ signature = "*sent from [neomd](https://neomd.ssp.sh)*"
+ bg_sync_interval = 5
+ bulk_progress_threshold = 0
+ draft_backup_count = 0
+ mark_as_read_after_secs = 7
+ [ui.signature_block]
+ text = ""
+ html = ""
+
+[notifications]
+ enabled = true
+ command = "notify-send"
+ icon = "mail-message-new"
+ expire_ms = 5000
+ folders = ["Inbox"]
+
+[ai]
+ command = "pi"
+ args = ["edit {file}: {prompt}"]
+
+[theme]
+ bg = ""
+ border = ""
+ subtle = ""
+ selected = ""
+ text = ""
+ muted = ""
+ primary = ""
+ unread = ""
+ number = ""
+ date = ""
+ author_read = ""
+ subject_read = ""
+ size_col = ""
+ author_unread = ""
+ subject_unread = ""
+ error = ""
+ success = ""
+
+[calendar]
+ open_command = ""
+
+[listmonk]
+ url = ""
+ api_user = ""
+ api_token = ""
+ delay_minutes = 0
diff --git a/.config/neomd/lists/feed.txt.gpg b/.config/neomd/lists/feed.txt.gpg
new file mode 100644
index 0000000..0846e54
Binary files /dev/null and b/.config/neomd/lists/feed.txt.gpg differ
diff --git a/.config/neomd/lists/notify.txt.gpg b/.config/neomd/lists/notify.txt.gpg
new file mode 100644
index 0000000..23f81b4
Binary files /dev/null and b/.config/neomd/lists/notify.txt.gpg differ
diff --git a/.config/neomd/lists/papertrail.txt.gpg b/.config/neomd/lists/papertrail.txt.gpg
new file mode 100644
index 0000000..a7f7816
Binary files /dev/null and b/.config/neomd/lists/papertrail.txt.gpg differ
diff --git a/.config/neomd/lists/screened_in.txt.gpg b/.config/neomd/lists/screened_in.txt.gpg
new file mode 100644
index 0000000..3bf7984
Binary files /dev/null and b/.config/neomd/lists/screened_in.txt.gpg differ
diff --git a/.config/neomd/lists/screened_out.txt.gpg b/.config/neomd/lists/screened_out.txt.gpg
new file mode 100644
index 0000000..d16afdf
--- /dev/null
+++ b/.config/neomd/lists/screened_out.txt.gpg
@@ -0,0 +1 @@
+„^ÁÂSu;Þ(@ôð‰"CÆc–ÌÈuŠöJÔ§²»Ë&öjæ¢<0d:篦›±&P¸ª'7\BˆœUöJøî"ÖŽƒú 룹H' „Ñ„ü°Ú8Mòæ‡Ò°—6
Õ™QÁœ‚Ÿ5ÙmQUÚO`Á¼+4¢G°µÞÞööß™36:mÌïØ$më÷Žè7š×š‡*Föî
XÄsîì1úPJb®Î¯rüN#…›¿Ûe:%EY°aÛR¢Õ@ÏΡ»Ï·»Ä”ôÈrIk!±
ÃP!U\·«oÝR÷,¬à¼6Û+”ɴƈ³ôÇZî’·äm*L£#,ef–)¶0Ò
\ No newline at end of file
diff --git a/.config/neomd/lists/spam.txt.gpg b/.config/neomd/lists/spam.txt.gpg
new file mode 100644
index 0000000..f27ec4b
--- /dev/null
+++ b/.config/neomd/lists/spam.txt.gpg
@@ -0,0 +1,2 @@
+„^ÁÂSu;Þ(@‰`”!½TpWrLcêDtø2²»±¢ˆe¥¦às0®[ Á*¼È5¾þPþÑîƒØµ¯3nÃ÷ÇNveÚö–
+ÂÓ•þ·bÂtv˜Ògö:í(ug…ƒàˆEÆ–¿TZá¤&°}E¯‘8œ“¼[Qn)óWö€rBË~få'Òeþ©kEukIv†ì)>)KEe/~Ò øqþ ×Ý„+˜`Åë*Ì&p;ß
\ No newline at end of file
diff --git a/.config/niri/binds.kdl b/.config/niri/binds.kdl
new file mode 100644
index 0000000..fa37195
--- /dev/null
+++ b/.config/niri/binds.kdl
@@ -0,0 +1,395 @@
+binds {
+ // Keys consist of modifiers separated by + signs, followed by an XKB key name
+ // in the end. To find an XKB name for a particular key, you may use a program
+ // like wev.
+ //
+ // "Mod" is a special modifier equal to Super when running on a TTY, and to Alt
+ // when running as a winit window.
+ //
+ // Most actions that you can bind here can also be invoked programmatically with
+ // `niri msg action do-something`.
+ Alt+Return hotkey-overlay-title="Open a Launcher" {
+ spawn-sh "qs -c noctalia-shell ipc call launcher toggle"
+ }
+ Alt+S hotkey-overlay-title="Open a Settings" {
+ spawn-sh "qs -c noctalia-shell ipc call settings toggle"
+ }
+ Alt+Shift+P hotkey-overlay-title="toggle lock screen" {
+ spawn-sh "qs -c noctalia-shell ipc call sessionMenu toggle"
+ }
+ Alt+Shift+W hotkey-overlay-title="Open a Wallpaper" {
+ spawn-sh "qs -c noctalia-shell ipc call wallpaper toggle"
+ }
+ Mod+E {
+ spawn-sh "qs -c noctalia-shell ipc call launcher emoji"
+ }
+ Alt+Shift+Slash {
+ show-hotkey-overlay
+ }
+ Mod+V hotkey-overlay-title="Open a Clipboard" {
+ spawn-sh "qs -c noctalia-shell ipc call plugin:clipmask toggle"
+ }
+ Mod+M hotkey-overlay-title="Open a music" { spawn-sh "qs -c noctalia-shell ipc call plugin:music launcher"; }
+ // Mod+M hotkey-overlay-title="Open a music player" {
+ // spawn "spotify"
+ // }
+ Alt+Shift+O hotkey-overlay-title="toggle window opacity" {
+ toggle-window-rule-opacity
+ }
+ // Suggested binds for running programs: terminal, app launcher, screen locker.
+ Mod+T hotkey-overlay-title="Open a Terminal: kitty" {
+ spawn "kitty" "--app-id" "kitty-terminal"
+ }
+ Mod+W hotkey-overlay-title="Open a sys-monitor: btop" {
+ spawn "kitty" "btop"
+ }
+ Mod+Shift+T hotkey-overlay-title="Open a Terminal: kitty" {
+ spawn "kitty" "--app-id" "kitty-float" "-1" "-o" "shell" "/bin/zsh"
+ }
+ Mod+D hotkey-overlay-title="Open a discord: vesktop" {
+ spawn "flatpak" "run" "dev.vencord.Vesktop"
+ }
+ Mod+O hotkey-overlay-title="Open a screen-rec: OBS" {
+ spawn "flatpak" "run" "com.obsproject.Studio"
+ }
+ Mod+B hotkey-overlay-title="Open a Brave-browser" {
+ spawn "brave-origin"
+ }
+ Mod+L hotkey-overlay-title="Open a localsend" {
+ spawn "flatpak" "run" "org.localsend.localsend_app"
+ }
+ Mod+J hotkey-overlay-title="Open Yazi" {
+ spawn "kitty" "lazydocker"
+ }
+ // Mod+F hotkey-overlay-title="Open a file manager" {
+ // spawn "nautilus"
+ // }
+ Mod+F hotkey-overlay-title="Open Yazi" {
+ spawn "kitty" "yazi"
+ }
+ Mod+S hotkey-overlay-title="Open a Volume" {
+ spawn-sh "qs -c noctalia-shell ipc call volume togglePanel"
+ }
+ // Alt+D hotkey-overlay-title="Run an Application: fuzzel" { spawn "fuzzel"; }
+ // Use spawn-sh to run a shell command. Do this if you need pipes, multiple commands, etc.
+ // Note: the entire command goes as a single argument. It's passed verbatim to `sh -c`.
+ // For example, this is a standard bind to toggle the screen reader (orca).
+ Super+Alt+S allow-when-locked=true hotkey-overlay-title=null {
+ spawn-sh "pkill orca || exec orca"
+ }
+ // Example volume keys mappings for PipeWire & WirePlumber.
+ // The allow-when-locked=true property makes them work even when the session is locked.
+ // Using spawn-sh allows to pass multiple arguments together with the command.
+ // "-l 1.0" limits the volume to 100%.
+ XF86AudioRaiseVolume allow-when-locked=true {
+ spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.1+ -l 1.0"
+ }
+ XF86AudioLowerVolume allow-when-locked=true {
+ spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.1-"
+ }
+ XF86AudioMute allow-when-locked=true {
+ spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
+ }
+ XF86AudioMicMute allow-when-locked=true {
+ spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"
+ }
+ // Example media keys mapping using playerctl.
+ // This will work with any MPRIS-enabled media player.
+ XF86AudioPlay allow-when-locked=true {
+ spawn-sh "playerctl play-pause"
+ }
+ XF86AudioStop allow-when-locked=true {
+ spawn-sh "playerctl stop"
+ }
+ XF86AudioPrev allow-when-locked=true {
+ spawn-sh "playerctl previous"
+ }
+ XF86AudioNext allow-when-locked=true {
+ spawn-sh "playerctl next"
+ }
+ // Example brightness key mappings for brightnessctl.
+ // You can use regular spawn with multiple arguments too (to avoid going through "sh"),
+ // but you need to manually put each argument in separate "" quotes.
+ XF86MonBrightnessUp {
+ spawn "qs" "-c" "noctalia-shell" "ipc" "call" "brightness" "increase"
+ }
+ XF86MonBrightnessDown {
+ spawn "qs" "-c" "noctalia-shell" "ipc" "call" "brightness" "decrease"
+ }
+ // Open/close the Overview: a zoomed-out view of workspaces and windows.
+ // You can also move the mouse into the top-left hot corner,
+ // or do a four-finger swipe up on a touchpad.
+ Alt+O repeat=false {
+ toggle-overview
+ }
+ Alt+Q repeat=false {
+ close-window
+ }
+ // Mod+Left { focus-column-left; }
+ // Mod+Down { focus-window-down; }
+ // Mod+Up { focus-window-up; }
+ // Mod+Right { focus-column-right; }
+ Alt+H {
+ focus-column-left
+ }
+ // Alt+J { focus-window-down; }
+ // Alt+K { focus-window-up; }
+ Alt+L {
+ focus-column-right
+ }
+ // Mod+Ctrl+Left { move-column-left; }
+ // Mod+Ctrl+Down { move-window-down; }
+ // Mod+Ctrl+Up { move-window-up; }
+ // Mod+Ctrl+Right { move-column-right; }
+ Alt+Shift+H {
+ move-column-left
+ }
+ // Alt+Shift+J { move-window-down; }
+ // Alt+Shift+K { move-window-up; }
+ Alt+Shift+L {
+ move-column-right
+ }
+ // Alternative commands that move across workspaces when reaching
+ // the first or last window in a column.
+ Alt+J {
+ focus-window-or-workspace-down
+ }
+ Alt+K {
+ focus-window-or-workspace-up
+ }
+ Alt+Shift+J {
+ move-window-down-or-to-workspace-down
+ }
+ Alt+Shift+K {
+ move-window-up-or-to-workspace-up
+ }
+ // Mod+Home { focus-column-first; }
+ // Mod+End { focus-column-last; }
+ // Mod+Ctrl+Home { move-column-to-first; }
+ // Mod+Ctrl+End { move-column-to-last; }
+ // You can bind mouse wheel scroll ticks using the following syntax.
+ // These binds will change direction based on the natural-scroll setting.
+ //
+ // To avoid scrolling through workspaces really fast, you can use
+ // the cooldown-ms property. The bind will be rate-limited to this value.
+ // You can set a cooldown on any bind, but it's most useful for the wheel.
+ Mod+WheelScrollDown cooldown-ms=150 {
+ focus-workspace-down
+ }
+ Mod+WheelScrollUp cooldown-ms=150 {
+ focus-workspace-up
+ }
+ Mod+Ctrl+WheelScrollDown cooldown-ms=150 {
+ move-column-to-workspace-down
+ }
+ Mod+Ctrl+WheelScrollUp cooldown-ms=150 {
+ move-column-to-workspace-up
+ }
+ Mod+WheelScrollRight {
+ focus-column-right
+ }
+ Mod+WheelScrollLeft {
+ focus-column-left
+ }
+ Mod+Ctrl+WheelScrollRight {
+ move-column-right
+ }
+ Mod+Ctrl+WheelScrollLeft {
+ move-column-left
+ }
+ // Usually scrolling up and down with Shift in applications results in
+ // horizontal scrolling; these binds replicate that.
+ Mod+Shift+WheelScrollDown {
+ focus-column-right
+ }
+ Mod+Shift+WheelScrollUp {
+ focus-column-left
+ }
+ Mod+Ctrl+Shift+WheelScrollDown {
+ move-column-right
+ }
+ Mod+Ctrl+Shift+WheelScrollUp {
+ move-column-left
+ }
+ // Similarly, you can bind touchpad scroll "ticks".
+ // Touchpad scrolling is continuous, so for these binds it is split into
+ // discrete intervals.
+ // These binds are also affected by touchpad's natural-scroll, so these
+ // example binds are "inverted", since we have natural-scroll enabled for
+ // touchpads by default.
+ Mod+TouchpadScrollDown {
+ spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.02+"
+ }
+ Mod+TouchpadScrollUp {
+ spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.02-"
+ }
+ // You can refer to workspaces by index. However, keep in mind that
+ // niri is a dynamic workspace system, so these commands are kind of
+ // "best effort". Trying to refer to a workspace index bigger than
+ // the current workspace count will instead refer to the bottommost
+ // (empty) workspace.
+ //
+ // For example, with 2 workspaces + 1 empty, indices 3, 4, 5 and so on
+ // will all refer to the 3rd workspace.
+ Alt+T {
+ focus-workspace "T"
+ }
+ Alt+B {
+ focus-workspace "B"
+ }
+ Alt+D {
+ focus-workspace "D"
+ }
+ Alt+N {
+ focus-workspace "N"
+ }
+ Alt+Shift+T {
+ move-column-to-workspace "T"
+ }
+ Alt+Shift+B {
+ move-column-to-workspace "B"
+ }
+ Alt+Shift+D {
+ move-column-to-workspace "D"
+ }
+ Alt+Shift+N {
+ move-column-to-workspace "N"
+ }
+ // Alternatively, there are commands to move just a single window:
+ // Mod+Ctrl+1 { move-window-to-workspace 1; }
+ // Switches focus between the current and the previous workspace.
+ Alt+Tab {
+ focus-workspace-previous
+ }
+ // The following binds move the focused window in and out of a column.
+ // If the window is alone, they will consume it into the nearby column to the side.
+ // If the window is already in a column, they will expel it out.
+ Mod+BracketLeft {
+ consume-or-expel-window-left
+ }
+ Mod+BracketRight {
+ consume-or-expel-window-right
+ }
+ // Consume one window from the right to the bottom of the focused column.
+ Alt+Comma {
+ consume-window-into-column
+ }
+ // Expel the bottom window from the focused column to the right.
+ Alt+Period {
+ expel-window-from-column
+ }
+ // Cycle through widths set in preset-column-widths.
+ Alt+R {
+ switch-preset-column-width
+ }
+ // Cycling through the presets in reverse order is also possible.
+ Alt+Shift+R {
+ switch-preset-column-width-back
+ }
+ Alt+Ctrl+Shift+R {
+ switch-preset-window-height
+ }
+ Alt+Ctrl+R {
+ reset-window-height
+ }
+ Alt+F {
+ maximize-column
+ }
+ Alt+Shift+F {
+ fullscreen-window
+ }
+ // While maximize-column leaves gaps and borders around the window,
+ // maximize-window-to-edges doesn't: the window expands to the edges of the screen.
+ // This bind corresponds to normal window maximizing,
+ // e.g. by double-clicking on the titlebar.
+ Alt+M {
+ maximize-window-to-edges
+ }
+ // Expand the focused column to space not taken up by other fully visible columns.
+ // Makes the column "fill the rest of the space".
+ Alt+Ctrl+F {
+ expand-column-to-available-width
+ }
+ Alt+C {
+ center-column
+ }
+ // Center all fully visible columns on screen.
+ Alt+Ctrl+C {
+ center-visible-columns
+ }
+ // Finer width adjustments.
+ // This command can also:
+ // * set width in pixels: "1000"
+ // * adjust width in pixels: "-5" or "+5"
+ // * set width as a percentage of screen width: "25%"
+ // * adjust width as a percentage of screen width: "-10%" or "+10%"
+ // Pixel sizes use logical, or scaled, pixels. I.e. on an output with scale 2.0,
+ // set-column-width "100" will make the column occupy 200 physical screen pixels.
+ Alt+Minus {
+ set-column-width "-10%"
+ }
+ Alt+Equal {
+ set-column-width "+10%"
+ }
+ // Finer height adjustments when in column with other windows.
+ Alt+Shift+Minus {
+ set-window-height "-10%"
+ }
+ Alt+Shift+Equal {
+ set-window-height "+10%"
+ }
+ // Move the focused window between the floating and the tiling layout.
+ Alt+V {
+ toggle-window-floating
+ }
+ Alt+Shift+V {
+ switch-focus-between-floating-and-tiling
+ }
+ // Toggle tabbed column display mode.
+ // Windows in this column will appear as vertical tabs,
+ // rather than stacked on top of each other.
+ Alt+W {
+ toggle-column-tabbed-display
+ }
+ // Actions to switch layouts.
+ // Note: if you uncomment these, make sure you do NOT have
+ // a matching layout switch hotkey configured in xkb options above.
+ // Having both at once on the same hotkey will break the switching,
+ // since it will switch twice upon pressing the hotkey (once by xkb, once by niri).
+ // Mod+Space { switch-layout "next"; }
+ // Mod+Shift+Space { switch-layout "prev"; }
+ // Print { screenshot; }
+ Ctrl+Shift+1 {
+ spawn-sh "screenshot.sh"
+ }
+ Ctrl+Shift+2 {
+ spawn-sh "screenshot.sh area"
+ }
+ Ctrl+Shift+3 {
+ spawn-sh "screenshot.sh edit"
+ }
+ Ctrl+Shift+4 {
+ spawn-sh "screenshot.sh edit-full"
+ }
+ // Applications such as remote-desktop clients and software KVM switches may
+ // request that niri stops processing the keyboard shortcuts defined here
+ // so they may, for example, forward the key presses as-is to a remote machine.
+ // It's a good idea to bind an escape hatch to toggle the inhibitor,
+ // so a buggy application can't hold your session hostage.
+ //
+ // The allow-inhibiting=false property can be applied to other binds as well,
+ // which ensures niri always processes them, even when an inhibitor is active.
+ Mod+Escape allow-inhibiting=false {
+ toggle-keyboard-shortcuts-inhibit
+ }
+ // The quit action will show a confirmation dialog to avoid accidental exits.
+ Alt+Shift+E {
+ quit
+ }
+ // Ctrl+Alt+Delete { quit; }
+
+ // Powers off the monitors. To turn them back on, do any input like
+ // moving the mouse or pressing any other key.
+ // Alt+Shift+P { power-off-monitors; }
+}
+
diff --git a/.config/niri/config.kdl b/.config/niri/config.kdl
new file mode 100644
index 0000000..ee5ad93
--- /dev/null
+++ b/.config/niri/config.kdl
@@ -0,0 +1,102 @@
+// This config is in the KDL format: https://kdl.dev
+// "/-" comments out the following node.
+// Check the wiki for a full description of the configuration:
+// https://niri-wm.github.io/niri/Configuration:-Introduction
+// Input device configuration.
+// Find the full list of options on the wiki:
+// https://niri-wm.github.io/niri/Configuration:-Input
+environment {
+ XDG_CURRENT_DESKTOP "niri"
+ XDG_SESSION_TYPE "wayland"
+ GDK_BACKEND "wayland"
+ T_QPA_PLATFORM "wayland"
+ QT_QPA_PLATFORMTHEME "qt6ct"
+ MOZ_ENABLE_WAYLAND "1"
+ SDL_VIDEO_DRIVER "wayland"
+ _JAVA_AWT_WM_NONREPARENTING "1"
+}
+workspace "T"
+workspace "B"
+workspace "D"
+workspace "N"
+include "inputs.kdl"
+// You can configure outputs by their name, which you can find
+// by running `niri msg outputs` while inside a niri instance.
+// The built-in laptop monitor is usually called "eDP-1".
+// Find more information on the wiki:
+// https://niri-wm.github.io/niri/Configuration:-Outputs
+// Remember to uncomment the node by removing "/-"!
+output "eDP-1"{
+ // off
+ mode "2880x1800@60.001"
+ scale 1.75
+ // transform "90"
+ // position x=1280 y=0
+ // variable-refresh-rate // on-demand=true
+ // focus-at-startup
+ // backdrop-color "#001100"
+}
+
+/-output "eDP-1" {
+// Uncomment this line to disable this output.
+// off
+// Resolution and, optionally, refresh rate of the output.
+// The format is "x" or "x@".
+// If the refresh rate is omitted, niri will pick the highest refresh rate
+// for the resolution.
+// If the mode is omitted altogether or is invalid, niri will pick one automatically.
+// Run `niri msg outputs` while inside a niri instance to list all outputs and their modes.
+mode "1920x1200@60.001"
+// You can use integer or fractional scale, for example use 1.5 for 150% scale.
+// scale 2
+// Transform allows to rotate the output counter-clockwise, valid values are:
+// normal, 90, 180, 270, flipped, flipped-90, flipped-180 and flipped-270.
+transform "normal"
+// Position of the output in the global coordinate space.
+// This affects directional monitor actions like "focus-monitor-left", and cursor movement.
+// The cursor can only move between directly adjacent outputs.
+// Output scale and rotation has to be taken into account for positioning:
+// outputs are sized in logical, or scaled, pixels.
+// For example, a 3840×2160 output with scale 2.0 will have a logical size of 1920×1080,
+// so to put another output directly adjacent to it on the right, set its x to 1920.
+// If the position is unset or results in an overlap, the output is instead placed
+automatically.
+// position x=1280 y=0
+}
+include "layouts.kdl"
+include "spwan.kdl"
+hotkey-overlay {
+ // Uncomment this line to disable the "Important Hotkeys" pop-up at startup.
+ skip-at-startup
+}
+// Uncomment this line to ask the clients to omit their client-side decorations if possible.
+// If the client will specifically ask for CSD, the request will be honored.
+// Additionally, clients will be informed that they are tiled, removing some client-side rounded corners.
+// This option will also fix border/focus ring drawing behind some semitransparent windows.
+// After enabling or disabling this, you need to restart the apps for this to take effect.
+prefer-no-csd
+// You can change the path where screenshots are saved.
+// A ~ at the front will be expanded to the home directory.
+// The path is formatted with strftime(3) to give you the screenshot date and time.
+screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png"
+// You can also set this to null to disable saving screenshots to disk.
+// screenshot-path null
+// Animation settings.
+// The wiki explains how to configure individual animations:
+// https://niri-wm.github.io/niri/Configuration:-Animations
+animations {// Uncomment to turn off all animations.
+ // off
+ // Slow down all animations by this factor. Values below 1 speed them up instead.
+ // slowdown 3.0
+ // window-open {
+ // spring damping-ratio=0.6 stiffness=500 epsilon=0.0001
+ // }
+ // window-movement {
+ // spring damping-ratio=0.5 stiffness=400 epsilon=0.0001
+ // }
+
+}
+include "rules.kdl"
+include "binds.kdl"
+include "./noctalia.kdl"
+
diff --git a/.config/niri/inputs.kdl b/.config/niri/inputs.kdl
new file mode 100644
index 0000000..7145295
--- /dev/null
+++ b/.config/niri/inputs.kdl
@@ -0,0 +1,69 @@
+input {
+ keyboard {
+ repeat-rate 25
+ repeat-delay 300
+ xkb {
+ // You can set rules, model, layout, variant and options.
+ // For more information, see xkeyboard-config(7).
+
+ // For example:
+ layout "us,ara"
+ variant ",mac"
+ options "grp:ctrl_space_toggle"
+
+ // If this section is empty, niri will fetch xkb settings
+ // from org.freedesktop.locale1. You can control these using
+ // localectl set-x11-keymap.
+ }
+
+ // Enable numlock on startup, omitting this setting disables it.
+ numlock
+ }
+
+ // Next sections include libinput settings.
+ // Omitting settings disables them, or leaves them at their default values.
+ // All commented-out settings here are examples, not defaults.
+ touchpad {
+ // off
+ tap
+ // dwt
+ // dwtp
+ // drag false
+ // drag-lock
+ natural-scroll
+ // accel-speed 0.2
+ // accel-profile "flat"
+ // scroll-method "two-finger"
+ // disabled-on-external-mouse
+ }
+
+ mouse {
+ // off
+ // natural-scroll
+ // accel-speed 0.2
+ // accel-profile "flat"
+ // scroll-method "no-scroll"
+ }
+
+ trackpoint {
+ // off
+ // natural-scroll
+ // accel-speed 0.2
+ // accel-profile "flat"
+ // scroll-method "on-button-down"
+ // scroll-button 273
+ // scroll-button-lock
+ // middle-emulation
+ }
+
+ // Uncomment this to make the mouse warp to the center of newly focused windows.
+ warp-mouse-to-focus
+
+ // Focus windows and outputs automatically when moving the mouse into them.
+ // Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
+ focus-follows-mouse max-scroll-amount="0%"
+}
+
+cursor {
+ hide-when-typing
+}
diff --git a/.config/niri/layouts.kdl b/.config/niri/layouts.kdl
new file mode 100644
index 0000000..0ab69ce
--- /dev/null
+++ b/.config/niri/layouts.kdl
@@ -0,0 +1,155 @@
+// Settings that influence how windows are positioned and sized.
+// Find more information on the wiki:
+// https://niri-wm.github.io/niri/Configuration:-Layout
+layout {
+ // Set gaps around windows in logical pixels.
+ gaps 15
+
+ // When to center a column when changing focus, options are:
+ // - "never", default behavior, focusing an off-screen column will keep at the left
+ // or right edge of the screen.
+ // - "always", the focused column will always be centered.
+ // - "on-overflow", focusing a column will center it if it doesn't fit
+ // together with the previously focused column.
+ center-focused-column "never"
+
+ // You can customize the widths that "switch-preset-column-width" (Mod+R) toggles between.
+ preset-column-widths {
+ // Proportion sets the width as a fraction of the output width, taking gaps into account.
+ // For example, you can perfectly fit four windows sized "proportion 0.25" on an output.
+ // The default preset widths are 1/3, 1/2 and 2/3 of the output.
+ proportion 0.33333
+ proportion 0.5
+ proportion 0.66667
+
+ // Fixed sets the width in logical pixels exactly.
+ // fixed 1920
+ }
+
+ // You can also customize the heights that "switch-preset-window-height" (Mod+Ctrl+Shift+R) toggles between.
+ // preset-window-heights { }
+
+ // You can change the default width of the new windows.
+ default-column-width { proportion 0.5; }
+ // If you leave the brackets empty, the windows themselves will decide their initial width.
+ // default-column-width {}
+
+ // By default focus ring and border are rendered as a solid background rectangle
+ // behind windows. That is, they will show up through semitransparent windows.
+ // This is because windows using client-side decorations can have an arbitrary shape.
+ //
+ // If you don't like that, you should uncomment `prefer-no-csd` below.
+ // Niri will draw focus ring and border *around* windows that agree to omit their
+ // client-side decorations.
+ //
+ // Alternatively, you can override it with a window rule called
+ // `draw-border-with-background`.
+
+ // You can change how the focus ring looks.
+ focus-ring {
+ // Uncomment this line to disable the focus ring.
+ // off
+
+ // How many logical pixels the ring extends out from the windows.
+ width 1.7
+
+ // Colors can be set in a variety of ways:
+ // - CSS named colors: "red"
+ // - RGB hex: "#rgb", "#rgba", "#rrggbb", "#rrggbbaa"
+ // - CSS-like notation: "rgb(255, 127, 0)", rgba(), hsl() and a few others.
+
+ // Color of the ring on the active monitor.
+ // active-color "#ffc87f"
+
+ // Color of the ring on inactive monitors.
+ //
+ // The focus ring only draws around the active window, so the only place
+ // where you can see its inactive-color is on other monitors.
+ // inactive-color "#505050"
+
+ // You can also use gradients. They take precedence over solid colors.
+ // Gradients are rendered the same as CSS linear-gradient(angle, from, to).
+ // The angle is the same as in linear-gradient, and is optional,
+ // defaulting to 180 (top-to-bottom gradient).
+ // You can use any CSS linear-gradient tool on the web to set these up.
+ // Changing the color space is also supported, check the wiki for more info.
+ //
+ // active-gradient from="#80c8ff" to="#c7ff7f" angle=45
+
+ // You can also color the gradient relative to the entire view
+ // of the workspace, rather than relative to just the window itself.
+ // To do that, set relative-to="workspace-view".
+ //
+ // inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
+ }
+
+ // You can also add a border. It's similar to the focus ring, but always visible.
+ border {
+ // The settings are the same as for the focus ring.
+ // If you enable the border, you probably want to disable the focus ring.
+ off
+
+ width 4
+ active-color "#ffc87f"
+ inactive-color "#505050"
+
+ // Color of the border around windows that request your attention.
+ urgent-color "#9b0000"
+
+ // Gradients can use a few different interpolation color spaces.
+ // For example, this is a pastel rainbow gradient via in="oklch longer hue".
+ //
+ // active-gradient from="#e5989b" to="#ffb4a2" angle=45 relative-to="workspace-view" in="oklch longer hue"
+
+ // inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
+ }
+
+ // You can enable drop shadows for windows.
+ shadow {
+ // Uncomment the next line to enable shadows.
+ on
+
+ // By default, the shadow draws only around its window, and not behind it.
+ // Uncomment this setting to make the shadow draw behind its window.
+ //
+ // Note that niri has no way of knowing about the CSD window corner
+ // radius. It has to assume that windows have square corners, leading to
+ // shadow artifacts inside the CSD rounded corners. This setting fixes
+ // those artifacts.
+ //
+ // However, instead you may want to set prefer-no-csd and/or
+ // geometry-corner-radius. Then, niri will know the corner radius and
+ // draw the shadow correctly, without having to draw it behind the
+ // window. These will also remove client-side shadows if the window
+ // draws any.
+ //
+ // draw-behind-window true
+
+ // You can change how shadows look. The values below are in logical
+ // pixels and match the CSS box-shadow properties.
+
+ // Softness controls the shadow blur radius.
+ softness 30
+
+ // Spread expands the shadow.
+ spread 5
+
+ // Offset moves the shadow relative to the window.
+ offset x=0 y=5
+
+ // You can also change the shadow color and opacity.
+ color "#0007"
+ }
+
+ // Struts shrink the area occupied by windows, similarly to layer-shell panels.
+ // You can think of them as a kind of outer gaps. They are set in logical pixels.
+ // Left and right struts will cause the next window to the side to always be visible.
+ // Top and bottom struts will simply add outer gaps in addition to the area occupied by
+ // layer-shell panels and regular gaps.
+ // struts {
+ // left 2
+ // right 2
+ // top 1
+ // bottom 3
+ // }
+}
diff --git a/.config/niri/noctalia.kdl b/.config/niri/noctalia.kdl
new file mode 100644
index 0000000..45461c6
--- /dev/null
+++ b/.config/niri/noctalia.kdl
@@ -0,0 +1,35 @@
+layout {
+
+ focus-ring {
+ active-color "#55dbc7"
+ inactive-color "#101413"
+ urgent-color "#ffb4ab"
+ }
+
+ border {
+ active-color "#55dbc7"
+ inactive-color "#101413"
+ urgent-color "#ffb4ab"
+ }
+
+ shadow {
+ color "#00000070"
+ }
+
+ tab-indicator {
+ active-color "#55dbc7"
+ inactive-color "#005047"
+ urgent-color "#ffb4ab"
+ }
+
+ insert-hint {
+ color "#55dbc780"
+ }
+}
+
+recent-windows {
+ highlight {
+ active-color "#55dbc7"
+ urgent-color "#ffb4ab"
+ }
+}
diff --git a/.config/niri/rules.kdl b/.config/niri/rules.kdl
new file mode 100644
index 0000000..5b2abf8
--- /dev/null
+++ b/.config/niri/rules.kdl
@@ -0,0 +1,176 @@
+// Window rules let you adjust behavior for individual windows.
+// Find more information on the wiki:
+// https://niri-wm.github.io/niri/Configuration:-Window-Rules
+
+
+// Work around WezTerm's initial configure bug
+// by setting an empty default-column-width.
+window-rule {
+ // This regular expression is intentionally made as specific as possible,
+ // since this is the default config, and we want no false positives.
+ // You can get away with just app-id="wezterm" if you want.
+ match app-id=r#"^org\.wezfurlong\.wezterm$"#
+ default-column-width {}
+ open-on-workspace "T"
+ open-focused true
+ // open-maximized true
+}
+
+window-rule {
+match app-id=r#"^com\.obsproject\.Studio$"#
+open-on-workspace "N"
+block-out-from "screencast"
+ open-focused true
+}
+
+window-rule {
+ match app-id=r#"one.alynx.showmethekey$"# title="^Floating Window - Show Me The Key$"
+ open-floating true
+ default-floating-position x=20 y=20 relative-to="bottom"
+}
+window-rule {
+ match app-id=r#"^localsend$"#
+ open-floating true
+ default-column-width { fixed 480; }
+ default-window-height { fixed 600; }
+ default-floating-position x=20 y=20 relative-to="top"
+}
+window-rule {
+ match app-id=r#"^org\.gnome\.Nautilus$"#
+ open-floating true
+ default-column-width { proportion 0.5; }
+ default-window-height { proportion 0.5; }
+ default-floating-position x=20 y=20 relative-to="top"
+}
+window-rule {
+ match app-id=r#"^com\.stremio\.Stremio$"#
+ open-on-workspace "N"
+ open-focused true
+ open-maximized true
+}
+window-rule {
+ match app-id=r#"^(app\.zen_browser\.zen|helium|brave-origin)$"#
+ open-on-workspace "B"
+ open-maximized true
+ open-focused true
+ // opacity 1.0
+}
+
+window-rule {
+ match app-id=r#"^Spotify$"#
+ open-on-workspace "D"
+ max-width 700
+ max-height 600
+ // default-column-width { fixed 580; }
+ // default-window-height { fixed 320; }
+ default-floating-position x=0 y=10 relative-to="top"
+ open-focused true
+ open-floating true
+}
+window-rule {
+ match app-id=r#"^kitty-terminal$"#
+ open-on-workspace "T"
+ open-focused true
+ open-maximized true
+}
+window-rule {
+ match app-id=r#"^kitty-float$"#
+ open-floating true
+ default-floating-position x=20 y=20 relative-to="top"
+ default-column-width { proportion 0.6; }
+ default-window-height { proportion 0.5; }
+}
+window-rule {
+ match app-id=r#"^mpv$"#
+ open-floating true
+ default-floating-position x=20 y=20 relative-to="top-right"
+ default-column-width { proportion 0.6; }
+ default-window-height { proportion 0.5; }
+}
+window-rule {
+ match app-id=r#"^kitty$"#
+ match title=r#"^Yazi:"#
+ open-floating true
+ default-floating-position x=20 y=20 relative-to="top"
+ default-column-width { proportion 0.6; }
+ default-window-height { proportion 0.5; }
+}
+window-rule {
+ match app-id=r#"^vesktop$"#
+ open-on-workspace "D"
+ open-maximized true
+ open-focused true
+}
+// Example: block out two password managers from screen capture.
+// (This example rule is commented out with a "/-" in front.)
+/-window-rule {
+ match app-id=r#"^org\.keepassxc\.KeePassXC$"#
+ match app-id=r#"^org\.gnome\.World\.Secrets$"#
+
+ block-out-from "screen-capture"
+
+ // Use this instead if you want them visible on third-party screenshot tools.
+ // block-out-from "screencast"
+}
+
+// Example: enable rounded corners for all windows.
+// (This example rule is commented out with a "/-" in front.)
+window-rule {
+ geometry-corner-radius 12
+ draw-border-with-background false
+ clip-to-geometry true
+ background-effect {
+ blur true
+ xray false
+ }
+}
+window-rule {
+ match is-active=true
+ exclude app-id=r#"^(brave-browser|app\.zen_browser\.zen|helium|brave-origin|ZenNotes|com.stremio.Stremio)$"#
+ opacity 0.85
+}
+// Inactive windows
+window-rule {
+ match is-active=false
+ opacity 0.67
+}
+// window-rule {
+// match app-id=r#"^(brave-browser|zen|helium|brave-origin)$"# title=r#"YouTube"#
+// opacity 1.0
+// }
+window-rule {
+ match title=r#"^[Pp]icture[- ][Ii]n[- ][Pp]icture$"#
+ open-floating true
+ default-floating-position x=20 y=20 relative-to="top-right"
+ default-column-width { fixed 580; }
+ default-window-height { fixed 320; }
+ clip-to-geometry true
+ opacity 0.9
+ geometry-corner-radius 12
+ border {
+ on
+ width 1
+ }
+}
+/* Apps: blur them all without xray for a better look */
+// window-rule {
+ // place-within-backdrop true
+// background-effect {
+// blur true
+// // xray true
+// }
+// }
+
+/* Noctalia: blur everywhere without xray for a better look */
+layer-rule {
+match namespace="^noctalia-(background|launcher-overlay|dock|wallpaper)-.*$"
+ place-within-backdrop true
+ background-effect {
+ xray false
+ blur true
+ }
+}
+// layer-rule {
+// match namespace="^noctalia-bar-content-.*$"
+// opacity 0.6 // or whatever value you want (0.0 = transparent, 1.0 = opaque)
+// }
diff --git a/.config/niri/scripts/niri-pip-daemon.sh b/.config/niri/scripts/niri-pip-daemon.sh
new file mode 100755
index 0000000..998baac
--- /dev/null
+++ b/.config/niri/scripts/niri-pip-daemon.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+
+PIP_REGEX='picture[._ -]?in[._ -]?picture|\bpip\b'
+
+get_focused_workspace() {
+ niri msg --json workspaces 2>/dev/null |
+ jq -r '.[] | select(.is_focused) | .idx'
+}
+
+get_pip_window() {
+ niri msg --json windows 2>/dev/null |
+ jq -r --arg regex "$PIP_REGEX" '
+ .[] | select(
+ .is_floating and
+ (.title | test($regex; "i"))
+ ) | .id
+ ' | head -n1
+}
+
+move_pip() {
+ local workspace="$1"
+ local wid
+ wid=$(get_pip_window)
+ [[ -z "$wid" ]] && return
+ niri msg action move-window-to-workspace "$workspace" \
+ --window-id "$wid" \
+ --focus false \
+ >/dev/null 2>&1
+}
+
+last_workspace=$(get_focused_workspace)
+
+niri msg --json event-stream 2>/dev/null | while read -r line; do
+ current_workspace=$(get_focused_workspace)
+
+ if [[ "$current_workspace" != "$last_workspace" ]]; then
+ move_pip "$current_workspace"
+ last_workspace="$current_workspace"
+ fi
+done
diff --git a/.config/niri/spwan.kdl b/.config/niri/spwan.kdl
new file mode 100644
index 0000000..5b784b2
--- /dev/null
+++ b/.config/niri/spwan.kdl
@@ -0,0 +1,16 @@
+// Add lines like this to spawn processes at startup.
+// Note that running niri as a session supports xdg-desktop-autostart,
+// which may be more convenient to use.
+// See the binds section below for more spawn examples.
+
+// This line starts waybar, a commonly used bar for Wayland compositors.
+// spawn-at-startup "waybar"
+// spawn-sh-at-startup "bash -c 'sleep 2 && qs -c noctalia-shell ipc call lockScreen lock'"
+spawn-sh-at-startup "/home/dox/.config/niri/scripts/niri-pip-daemon.sh"
+spawn-sh-at-startup "qs -c noctalia-shell"
+// spawn-at-startup "wl-paste" "--type" "text" "--watch" "cliphist" "store"
+// spawn-at-startup "wl-paste" "--type" "image" "--watch" "cliphist" "store"
+// spawn-at-startup "qs" "-c" "noctalia-shell"
+// To run a shell command (with variables, pipes, etc.), use spawn-sh-at-startup:
+// spawn-sh-at-startup "qs -c ~/source/qs/MyAwesomeShell"
+
diff --git a/.config/noctalia/colors.json b/.config/noctalia/colors.json
new file mode 100644
index 0000000..96f46be
--- /dev/null
+++ b/.config/noctalia/colors.json
@@ -0,0 +1,25 @@
+{
+ "mPrimary": "#55dbc7",
+ "mOnPrimary": "#003731",
+
+ "mSecondary": "#b1ccc6",
+ "mOnSecondary": "#1c3530",
+
+ "mTertiary": "#accae5",
+ "mOnTertiary": "#143349",
+
+ "mError": "#ffb4ab",
+ "mOnError": "#690005",
+
+ "mSurface": "#101413",
+ "mOnSurface": "#e0e3e1",
+
+ "mSurfaceVariant": "#1d201f",
+ "mOnSurfaceVariant": "#bec9c5",
+
+ "mOutline": "#3f4946",
+ "mShadow": "#000000",
+
+ "mHover": "#accae5",
+ "mOnHover": "#143349"
+}
\ No newline at end of file
diff --git a/.config/noctalia/colorschemes/Everforest/Everforest.json b/.config/noctalia/colorschemes/Everforest/Everforest.json
new file mode 100644
index 0000000..da7a8c7
--- /dev/null
+++ b/.config/noctalia/colorschemes/Everforest/Everforest.json
@@ -0,0 +1,94 @@
+{
+ "dark": {
+ "mPrimary": "#A7C080",
+ "mOnPrimary": "#232A2E",
+ "mSecondary": "#D3C6AA",
+ "mOnSecondary": "#232A2E",
+ "mTertiary": "#9DA9A0",
+ "mOnTertiary": "#232A2E",
+ "mError": "#E67E80",
+ "mOnError": "#232A2E",
+ "mSurface": "#232A2E",
+ "mOnSurface": "#859289",
+ "mSurfaceVariant": "#2D353B",
+ "mOnSurfaceVariant": "#D3C6AA",
+ "mOutline": "#7A8478",
+ "mShadow": "#475258",
+ "mHover": "#A7C080",
+ "mOnHover": "#232A2E",
+ "terminal": {
+ "foreground": "#d3c6aa",
+ "background": "#1e2326",
+ "selectionFg": "#d3c6aa",
+ "selectionBg": "#4c3743",
+ "cursorText": "#4c3743",
+ "cursor": "#e69875",
+ "normal": {
+ "black": "#7a8478",
+ "red": "#e67e80",
+ "green": "#a7c080",
+ "yellow": "#dbbc7f",
+ "blue": "#7fbbb3",
+ "magenta": "#d699b6",
+ "cyan": "#83c092",
+ "white": "#f2efdf"
+ },
+ "bright": {
+ "black": "#a6b0a0",
+ "red": "#f85552",
+ "green": "#8da101",
+ "yellow": "#dfa000",
+ "blue": "#3a94c5",
+ "magenta": "#df69ba",
+ "cyan": "#35a77c",
+ "white": "#fffbef"
+ }
+ }
+ },
+ "light": {
+ "mPrimary": "#434F55",
+ "mOnPrimary": "#D3C6AA",
+ "mSecondary": "#232a2e",
+ "mOnSecondary": "#D3C6AA",
+ "mTertiary": "#333c43",
+ "mOnTertiary": "#9DA9A0",
+ "mError": "#E66868",
+ "mOnError": "#9DA9A0",
+ "mSurface": "#9DA9A0",
+ "mOnSurface": "#232A2E",
+ "mSurfaceVariant": "#BEC5B2",
+ "mOnSurfaceVariant": "#333C43",
+ "mOutline": "#232A2E",
+ "mShadow": "#ECF5ED",
+ "mHover": "#333c43",
+ "mOnHover": "#9DA9A0",
+ "terminal": {
+ "foreground": "#5c6a72",
+ "background": "#efebd4",
+ "selectionFg": "#5c6a72",
+ "selectionBg": "#eaedc8",
+ "cursorText": "#eaedc8",
+ "cursor": "#f57d26",
+ "normal": {
+ "black": "#7a8478",
+ "red": "#e67e80",
+ "green": "#9ab373",
+ "yellow": "#c1a266",
+ "blue": "#7fbbb3",
+ "magenta": "#d699b6",
+ "cyan": "#83c092",
+ "white": "#b2af9f"
+ },
+ "bright": {
+ "black": "#a6b0a0",
+ "red": "#f85552",
+ "green": "#8da101",
+ "yellow": "#dfa000",
+ "blue": "#3a94c5",
+ "magenta": "#df69ba",
+ "cyan": "#35a77c",
+ "white": "#fffbef"
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/colorschemes/Oasis Abyss/Oasis Abyss.json b/.config/noctalia/colorschemes/Oasis Abyss/Oasis Abyss.json
new file mode 100644
index 0000000..ce509da
--- /dev/null
+++ b/.config/noctalia/colorschemes/Oasis Abyss/Oasis Abyss.json
@@ -0,0 +1,90 @@
+{
+ "dark": {
+ "mPrimary": "#D06666",
+ "mOnPrimary": "#1A1A1A",
+ "mSecondary": "#FFA247",
+ "mOnSecondary": "#1A1A1A",
+ "mTertiary": "#F0E68C",
+ "mOnTertiary": "#1A1A1A",
+ "mError": "#FFA0A0",
+ "mOnError": "#1A1A1A",
+ "mSurface": "#1A1A1A",
+ "mOnSurface": "#FFDACC",
+ "mSurfaceVariant": "#080808",
+ "mOnSurfaceVariant": "#F0EBE6",
+ "mOutline": "#5E5148",
+ "mShadow": "#050505",
+ "terminal": {
+ "cursorText": "#000000",
+ "cursor": "#F58888",
+ "foreground": "#F0EBE6",
+ "background": "#000000",
+ "selectionFg": "#F0EBE6",
+ "selectionBg": "#5A3824",
+ "normal": {
+ "black": "#000000",
+ "red": "#D06666",
+ "green": "#53D390",
+ "yellow": "#F0E68C",
+ "blue": "#519BFF",
+ "magenta": "#C28EFF",
+ "cyan": "#5ABAAE",
+ "white": "#DDDBD5"
+ },
+ "bright": {
+ "black": "#666666",
+ "red": "#FFA0A0",
+ "green": "#96EA7F",
+ "yellow": "#FFA247",
+ "blue": "#87CEEB",
+ "magenta": "#D2ADFF",
+ "cyan": "#8FD1C7",
+ "white": "#FFF9F2"
+ }
+ }
+ },
+ "light": {
+ "mPrimary": "#7d2b2b",
+ "mOnPrimary": "#e8e0d8",
+ "mSecondary": "#ab5807",
+ "mOnSecondary": "#e8e0d8",
+ "mTertiary": "#696223",
+ "mOnTertiary": "#e8e0d8",
+ "mError": "#b36d43",
+ "mOnError": "#e8e0d8",
+ "mSurface": "#e8e0d8",
+ "mOnSurface": "#7c4734",
+ "mSurfaceVariant": "#f3efeb",
+ "mOnSurfaceVariant": "#555555",
+ "mOutline": "#978e88",
+ "mShadow": "#eee8e2",
+ "terminal": {
+ "cursorText": "#f9f7f5",
+ "cursor": "#bb2328",
+ "foreground": "#5c534b",
+ "background": "#f9f7f5",
+ "selectionFg": "#5c534b",
+ "selectionBg": "#e8e0d8",
+ "normal": {
+ "black": "#555555",
+ "red": "#a44040",
+ "green": "#306c4c",
+ "yellow": "#696223",
+ "blue": "#155ec0",
+ "magenta": "#7f30dd",
+ "cyan": "#376a62",
+ "white": "#58554c"
+ },
+ "bright": {
+ "black": "#555555",
+ "red": "#c21313",
+ "green": "#366d27",
+ "yellow": "#93500e",
+ "blue": "#266884",
+ "magenta": "#7f31de",
+ "cyan": "#356a62",
+ "white": "#7d490c"
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/colorschemes/Solarized/Solarized.json b/.config/noctalia/colorschemes/Solarized/Solarized.json
new file mode 100644
index 0000000..9a63541
--- /dev/null
+++ b/.config/noctalia/colorschemes/Solarized/Solarized.json
@@ -0,0 +1,94 @@
+{
+ "dark": {
+ "mPrimary": "#b58900",
+ "mOnPrimary": "#002b36",
+ "mSecondary": "#d33682",
+ "mOnSecondary": "#002b36",
+ "mTertiary": "#cb4b16",
+ "mOnTertiary": "#002b36",
+ "mError": "#dc322f",
+ "mOnError": "#002b36",
+ "mSurface": "#002b36",
+ "mOnSurface": "#839496",
+ "mSurfaceVariant": "#073642",
+ "mOnSurfaceVariant": "#657b83",
+ "mOutline": "#0c5c70",
+ "mShadow": "#002b36",
+ "mHover": "#cb4b16",
+ "mOnHover": "#002b36",
+ "terminal": {
+ "foreground": "#839496",
+ "background": "#002b36",
+ "selectionFg": "#93a1a1",
+ "selectionBg": "#073642",
+ "cursorText": "#073642",
+ "cursor": "#839496",
+ "normal": {
+ "black": "#073642",
+ "red": "#dc322f",
+ "green": "#859900",
+ "yellow": "#b58900",
+ "blue": "#268bd2",
+ "magenta": "#d33682",
+ "cyan": "#2aa198",
+ "white": "#eee8d5"
+ },
+ "bright": {
+ "black": "#335e69",
+ "red": "#cb4b16",
+ "green": "#586e75",
+ "yellow": "#657b83",
+ "blue": "#839496",
+ "magenta": "#6c71c4",
+ "cyan": "#93a1a1",
+ "white": "#fdf6e3"
+ }
+ }
+ },
+ "light": {
+ "mPrimary": "#b58900",
+ "mOnPrimary": "#fdf6e3",
+ "mSecondary": "#d33682",
+ "mOnSecondary": "#fdf6e3",
+ "mTertiary": "#cb4b16",
+ "mOnTertiary": "#fdf6e3",
+ "mError": "#dc322f",
+ "mOnError": "#fdf6e3",
+ "mSurface": "#fdf6e3",
+ "mOnSurface": "#657b83",
+ "mSurfaceVariant": "#eee8d5",
+ "mOnSurfaceVariant": "#839496",
+ "mOutline": "#dfd4b1",
+ "mShadow": "#eee8d5",
+ "mHover": "#cb4b16",
+ "mOnHover": "#fdf6e3",
+ "terminal": {
+ "foreground": "#657b83",
+ "background": "#fdf6e3",
+ "selectionFg": "#586e75",
+ "selectionBg": "#eee8d5",
+ "cursorText": "#eee8d5",
+ "cursor": "#657b83",
+ "normal": {
+ "black": "#073642",
+ "red": "#dc322f",
+ "green": "#859900",
+ "yellow": "#b58900",
+ "blue": "#268bd2",
+ "magenta": "#d33682",
+ "cyan": "#2aa198",
+ "white": "#bbb5a2"
+ },
+ "bright": {
+ "black": "#002b36",
+ "red": "#cb4b16",
+ "green": "#586e75",
+ "yellow": "#657b83",
+ "blue": "#839496",
+ "magenta": "#6c71c4",
+ "cyan": "#93a1a1",
+ "white": "#fdf6e3"
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins.json b/.config/noctalia/plugins.json
new file mode 100644
index 0000000..5deab0b
--- /dev/null
+++ b/.config/noctalia/plugins.json
@@ -0,0 +1,40 @@
+{
+ "sources": [
+ {
+ "enabled": true,
+ "name": "Noctalia Plugins",
+ "url": "https://github.com/noctalia-dev/noctalia-plugins"
+ }
+ ],
+ "states": {
+ "catwalk": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "dns-switcher": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "music-search": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "polkit-agent": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "privacy-indicator": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "screen-recorder": {
+ "enabled": true,
+ "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins"
+ },
+ "clipmask": {
+ "enabled": true,
+ "sourceUrl": "local"
+ }
+ },
+ "version": 2
+}
diff --git a/.config/noctalia/plugins/catwalk/BarWidget.qml b/.config/noctalia/plugins/catwalk/BarWidget.qml
new file mode 100644
index 0000000..ad38e6b
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/BarWidget.qml
@@ -0,0 +1,193 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Modules.Bar.Extras
+import qs.Services.UI
+import qs.Widgets
+import qs.Services.System
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ // Per-screen bar properties
+ readonly property string screenName: screen?.name ?? ""
+ readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
+ readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
+ readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
+
+ property url currentIconSource
+
+ property string tooltipText: {
+ if (!pluginApi) return "";
+ return root.isRunning ? (pluginApi.tr("tooltip.running") || "Running") : (pluginApi.tr("tooltip.sleeping") || "Sleeping");
+ }
+
+ property string tooltipDirection: BarService.getTooltipDirection()
+ property bool enabled: true
+ property bool allowClickWhenDisabled: false
+ property bool hovering: false
+
+ property color colorBg: Color.mSurfaceVariant
+ property color colorFg: Color.mPrimary
+ property color colorBgHover: Color.mHover
+ property color colorFgHover: Color.mOnHover
+ property color colorBorder: Color.mOutline
+ property color colorBorderHover: Color.mOutline
+ property real customRadius: Style.radiusL
+
+ signal entered
+ signal exited
+ signal clicked
+ signal rightClicked
+ signal middleClicked
+ signal wheel(int angleDelta)
+
+ readonly property real contentWidth: barIsVertical ? capsuleHeight : Math.round(capsuleHeight + Style.marginXS * 2)
+ readonly property real contentHeight: capsuleHeight
+
+ implicitWidth: contentWidth
+ implicitHeight: contentHeight
+
+ // --- Catwalk Specific Logic ---
+ property int frameIndex: 0
+ property int idleFrameIndex: 0
+ readonly property bool isRunning: root.pluginApi?.mainInstance?.isRunning ?? false
+ readonly property var icons: root.pluginApi?.mainInstance?.icons || []
+ readonly property var idleIcons: root.pluginApi?.mainInstance?.idleIcons || []
+ readonly property real cpuUsage: root.pluginApi?.mainInstance?.cpuUsage ?? 0
+
+ function openPanel() {
+ if (pluginApi) {
+ var result = pluginApi.openPanel(root.screen);
+ Logger.i("Catwalk", "OpenPanel result:", result);
+ } else {
+ Logger.e("Catwalk", "PluginAPI is null");
+ }
+ }
+
+ function openExternalMonitor() {
+ Quickshell.execDetached(["sh", "-c", Settings.data.systemMonitor.externalMonitor]);
+ }
+
+ Timer {
+ interval: Math.max(30, 200 - root.cpuUsage * 1.7)
+ running: root.isRunning
+ repeat: true
+ onTriggered: {
+ root.frameIndex = (root.frameIndex + 1) % root.icons.length
+ }
+ }
+
+ Timer {
+ interval: 400
+ running: !root.isRunning
+ repeat: true
+ onTriggered: {
+ root.idleFrameIndex = (root.idleFrameIndex + 1) % root.idleIcons.length
+ }
+ }
+
+ currentIconSource: (root.icons && root.icons.length > 0 && root.idleIcons && root.idleIcons.length > 0)
+ ? (root.isRunning
+ ? Qt.resolvedUrl(root.icons[root.frameIndex % root.icons.length])
+ : Qt.resolvedUrl(root.idleIcons[root.idleFrameIndex % root.idleIcons.length]))
+ : ""
+
+ Rectangle {
+ id: visualCapsule
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+ width: root.contentWidth
+ height: root.contentHeight
+ opacity: root.enabled ? Style.opacityFull : Style.opacityMedium
+ color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor
+ radius: Math.min((customRadius >= 0 ? customRadius : Style.iRadiusL), width / 2)
+ border.color: Style.capsuleBorderColor
+ border.width: Style.capsuleBorderWidth
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ Image {
+ id: iconImage
+ source: root.currentIconSource
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+
+ width: Style.toOdd(visualCapsule.width - Style.marginXS * 2)
+ height: width
+
+ // Render SVG at exact target size for crisp output
+ sourceSize: Qt.size(width, height)
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: false
+
+ // This enables the "mask" behavior to recolor the icon
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ colorization: 1.0
+ colorizationColor: Settings.data.colorSchemes.darkMode ? "white" : "black"
+ }
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ hoverEnabled: true
+ onEntered: {
+ root.hovering = true;
+ if (root.tooltipText) {
+ TooltipService.show(root, root.tooltipText, root.tooltipDirection);
+ }
+ root.entered();
+ }
+ onExited: {
+ root.hovering = false;
+ if (root.tooltipText) {
+ TooltipService.hide();
+ }
+ root.exited();
+ }
+ onClicked: function (mouse) {
+ if (root.tooltipText) {
+ TooltipService.hide();
+ }
+
+ Logger.i("Catwalk", "Clicked! API:", !!pluginApi, "Screen:", root.screen ? root.screen.name : "null");
+
+ if (!root.enabled && !root.allowClickWhenDisabled) {
+ return;
+ }
+ // Open Panel on left/right click
+ // Open external monitor on middle click
+ if (mouse.button === Qt.LeftButton) {
+ root.openPanel();
+ root.clicked();
+ } else if (mouse.button === Qt.RightButton) {
+ root.openPanel();
+ root.rightClicked();
+ } else if (mouse.button === Qt.MiddleButton) {
+ root.openExternalMonitor();
+ root.middleClicked();
+ }
+ }
+ onWheel: wheel => root.wheel(wheel.angleDelta.y)
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/DesktopWidget.qml b/.config/noctalia/plugins/catwalk/DesktopWidget.qml
new file mode 100644
index 0000000..931bf33
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/DesktopWidget.qml
@@ -0,0 +1,85 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+import Quickshell
+import qs.Commons
+import qs.Modules.DesktopWidgets
+import qs.Widgets
+import qs.Services.System
+
+DraggableDesktopWidget {
+ id: root
+ property var pluginApi: null
+
+ implicitWidth: 200
+ implicitHeight: 80
+
+ showBackground: !(root.pluginApi?.mainInstance?.hideBackground ?? false)
+
+ property int frameIndex: 0
+
+ readonly property var icons: root.pluginApi?.mainInstance?.icons || []
+
+ property int idleFrameIndex: 0
+ readonly property var idleIcons: root.pluginApi?.mainInstance?.idleIcons || []
+
+ readonly property bool isRunning: root.pluginApi?.mainInstance?.isRunning ?? false
+ readonly property real cpuUsage: root.pluginApi?.mainInstance?.cpuUsage ?? 0
+
+ Timer {
+ interval: Math.max(30, 200 - root.cpuUsage * 1.7)
+ running: root.isRunning
+ repeat: true
+ onTriggered: {
+ root.frameIndex = (root.frameIndex + 1) % root.icons.length
+ }
+ }
+
+ Timer {
+ interval: 400
+ running: !root.isRunning
+ repeat: true
+ onTriggered: {
+ root.idleFrameIndex = (root.idleFrameIndex + 1) % root.idleIcons.length
+ }
+ }
+
+ property url currentIconSource: (root.icons && root.icons.length > 0 && root.idleIcons && root.idleIcons.length > 0)
+ ? (root.isRunning
+ ? Qt.resolvedUrl(root.icons[root.frameIndex % root.icons.length])
+ : Qt.resolvedUrl(root.idleIcons[root.idleFrameIndex % root.idleIcons.length]))
+ : ""
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 5
+
+ Image {
+ id: iconImage
+ source: root.currentIconSource
+ Layout.fillHeight: true
+ Layout.preferredWidth: height
+
+ sourceSize.height: height
+ sourceSize.width: width
+
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: false
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ colorization: 1.0
+ colorizationColor: Settings.data.colorSchemes.darkMode ? "white" : "black"
+ }
+ }
+
+ Text {
+ text: Math.round(root.cpuUsage) + "%"
+ color: Settings.data.colorSchemes.darkMode ? "white" : "black"
+ font.bold: true
+ font.pixelSize: 40
+ Layout.alignment: Qt.AlignVCenter
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/Main.qml b/.config/noctalia/plugins/catwalk/Main.qml
new file mode 100644
index 0000000..7096062
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/Main.qml
@@ -0,0 +1,18 @@
+import QtQuick
+import Quickshell
+import qs.Services.System
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ readonly property real minimumThreshold: pluginApi?.pluginSettings?.minimumThreshold || 10
+ readonly property bool hideBackground: pluginApi?.pluginSettings?.hideBackground ?? false
+
+ property real cpuUsage: SystemStatService.cpuUsage
+ readonly property bool isRunning: cpuUsage >= minimumThreshold
+
+ readonly property var icons: ["icons/my-active-0-symbolic.svg", "icons/my-active-1-symbolic.svg", "icons/my-active-2-symbolic.svg", "icons/my-active-3-symbolic.svg", "icons/my-active-4-symbolic.svg"]
+ readonly property var idleIcons: ["icons/my-idle-0-symbolic.svg", "icons/my-idle-1-symbolic.svg", "icons/my-idle-2-symbolic.svg", "icons/my-idle-3-symbolic.svg"]
+}
diff --git a/.config/noctalia/plugins/catwalk/Panel.qml b/.config/noctalia/plugins/catwalk/Panel.qml
new file mode 100644
index 0000000..0eeb67c
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/Panel.qml
@@ -0,0 +1,102 @@
+import QtQuick
+import QtQuick.Effects
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.System
+import qs.Widgets
+
+Item {
+ id: root
+ property var pluginApi: null
+
+ // SmartPanel properties
+ readonly property var geometryPlaceholder: panelContainer
+ readonly property bool allowAttach: true
+ property real contentPreferredWidth: 300 * Style.uiScaleRatio
+ property real contentPreferredHeight: 300 * Style.uiScaleRatio
+
+ anchors.fill: parent
+
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ color: Color.mSurface
+ radius: Style.radiusL
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Style.marginL
+
+ // Big Cat
+ Item {
+ id: bigCatItem
+ Layout.preferredWidth: 128 * Style.uiScaleRatio
+ Layout.preferredHeight: 128 * Style.uiScaleRatio
+ Layout.alignment: Qt.AlignHCenter
+
+ property int frameIndex: 0
+
+ readonly property bool isRunning: root.pluginApi?.mainInstance?.isRunning ?? false
+ readonly property var icons: root.pluginApi?.mainInstance?.icons || []
+
+ property int idleFrameIndex: 0
+ readonly property var idleIcons: root.pluginApi?.mainInstance?.idleIcons || []
+
+ readonly property real cpuUsage: root.pluginApi?.mainInstance?.cpuUsage ?? 0
+
+ Timer {
+ interval: Math.max(30, 200 - bigCatItem.cpuUsage * 1.7)
+ running: bigCatItem.isRunning
+ repeat: true
+ onTriggered: bigCatItem.frameIndex = (bigCatItem.frameIndex + 1) % bigCatItem.icons.length
+ }
+
+ Timer {
+ interval: 400
+ running: !bigCatItem.isRunning
+ repeat: true
+ onTriggered: bigCatItem.idleFrameIndex = (bigCatItem.idleFrameIndex + 1) % bigCatItem.idleIcons.length
+ }
+
+ Image {
+ id: bigCatImage
+ anchors.fill: parent
+
+ source: (bigCatItem.icons && bigCatItem.icons.length > 0 && bigCatItem.idleIcons && bigCatItem.idleIcons.length > 0)
+ ? (bigCatItem.isRunning
+ ? Qt.resolvedUrl(bigCatItem.icons[bigCatItem.frameIndex % bigCatItem.icons.length])
+ : Qt.resolvedUrl(bigCatItem.idleIcons[bigCatItem.idleFrameIndex % bigCatItem.idleIcons.length]))
+ : ""
+
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: true
+
+ // This handles the programmatic coloring
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ colorization: 1.0
+ colorizationColor: Settings.data.colorSchemes.darkMode ? "white" : "black"
+ }
+ }
+ }
+
+ // CPU Stats
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: (pluginApi?.tr("panel.cpuLabel") || "CPU: {usage}%").replace("{usage}", Math.round(root.pluginApi?.mainInstance?.cpuUsage ?? 0))
+ font.pointSize: Style.fontSizeXL
+ font.weight: Font.Bold
+ color: Settings.data.colorSchemes.darkMode ? "white" : "black"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/catwalk/README.md b/.config/noctalia/plugins/catwalk/README.md
new file mode 100644
index 0000000..52b094c
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/README.md
@@ -0,0 +1,34 @@
+# Catwalk Plugin for Noctalia
+
+A cute animated cat for your Noctalia bar that reacts to your system's CPU usage.
+
+## Features
+
+- **Animated Cat**: The cat walks/runs on your bar based on CPU usage
+- **CPU-Based Animation**:
+ - Below minimum threshold: Shows idle animation with "Zz" bubbles
+ - Above minimum threshold: Walks faster as CPU usage increases
+ - Speed scales continuously with CPU load
+- **Popup Panel**: Click the cat to open a larger animated version with CPU stats
+- **Theme Support**: Automatically switches between light/dark mode icons
+- **Configurable Settings**: Adjust the minimum CPU threshold for running animation
+
+## Installation
+
+This plugin is part of the `noctalia-plugins` repository.
+
+## Configuration
+
+Access the plugin settings in Noctalia to configure:
+
+- **Minimum CPU Threshold**: Set the CPU usage percentage (5-25%) above which the cat starts running. Below this, it stays idle with "Zz" animation.
+
+## Usage
+
+- The cat icon appears on your bar
+- It automatically animates based on your CPU usage
+- Click to open the CPU stats panel
+
+## Requirements
+
+- Noctalia 3.6.0 or later
diff --git a/.config/noctalia/plugins/catwalk/Settings.qml b/.config/noctalia/plugins/catwalk/Settings.qml
new file mode 100644
index 0000000..c1721bf
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/Settings.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Widgets
+import qs.Commons
+
+ColumnLayout {
+ id: root
+
+ // Plugin API (injected by the settings dialog system)
+ property var pluginApi: null
+
+ // Local state - track changes before saving
+ property real valueMinimumThreshold: pluginApi?.mainInstance?.minimumThreshold ?? (pluginApi?.pluginSettings?.minimumThreshold || 10)
+ property bool valueHideBackground: pluginApi?.mainInstance?.hideBackground ?? (pluginApi?.pluginSettings?.hideBackground ?? false)
+
+ spacing: Style.marginM
+
+ Component.onCompleted: {
+ Logger.i("Catwalk", "Settings UI loaded");
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NLabel {
+ label: pluginApi?.tr("settings.minimumThreshold.label") || "Minimum CPU Threshold"
+ description: pluginApi?.tr("settings.minimumThreshold.description") || "CPU usage must be above this percentage for the cat to start running"
+ }
+
+ NSlider {
+ id: thresholdSlider
+ from: 5
+ to: 25
+ value: root.valueMinimumThreshold
+ stepSize: 1
+ onValueChanged: {
+ root.valueMinimumThreshold = value
+ }
+ }
+
+ Text {
+ text: (pluginApi?.tr("settings.currentThreshold") || "Current threshold: {value}%").replace("{value}", thresholdSlider.value)
+ color: Color.mOnSurfaceVariant
+ font.pointSize: Style.fontSizeS
+ }
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.hideBackground.label") || "Hide Background"
+ description: pluginApi?.tr("settings.hideBackground.description") || "Hide the background of the desktop widget"
+
+ checked: root.valueHideBackground
+ onToggled: function(checked) {
+ root.valueHideBackground = checked
+ }
+ }
+
+ // This function is called by the dialog to save settings
+ function saveSettings() {
+ if (!pluginApi) {
+ Logger.e("Catwalk", "Cannot save settings: pluginApi is null");
+ return;
+ }
+
+ // Update the plugin settings object
+ pluginApi.pluginSettings.minimumThreshold = root.valueMinimumThreshold;
+ pluginApi.pluginSettings.hideBackground = root.valueHideBackground;
+
+ // Save to disk
+ pluginApi.saveSettings();
+
+ Logger.i("Catwalk", "Settings saved successfully");
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/catwalk/i18n/de.json b/.config/noctalia/plugins/catwalk/i18n/de.json
new file mode 100644
index 0000000..4424d88
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/de.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Aktuelle Schwelle: {value}%",
+ "hideBackground": {
+ "description": "Verstecke den Hintergrund des Desktop-Widgets",
+ "label": "Hintergrund ausblenden"
+ },
+ "minimumThreshold": {
+ "description": "Die CPU-Auslastung muss über diesem Prozentsatz liegen, damit die Katze zu laufen beginnt",
+ "label": "Minimale CPU-Schwelle"
+ },
+ "title": "Catwalk-Einstellungen"
+ },
+ "tooltip": {
+ "running": "Katze läuft",
+ "sleeping": "Katze schläft"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/en.json b/.config/noctalia/plugins/catwalk/i18n/en.json
new file mode 100644
index 0000000..93248a1
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/en.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Current threshold: {value}%",
+ "hideBackground": {
+ "description": "Hide the background of the desktop widget",
+ "label": "Hide Background"
+ },
+ "minimumThreshold": {
+ "description": "CPU usage must be above this percentage for the cat to start running",
+ "label": "Minimum CPU Threshold"
+ },
+ "title": "Catwalk Settings"
+ },
+ "tooltip": {
+ "running": "Cat is running",
+ "sleeping": "Cat is sleeping"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/es.json b/.config/noctalia/plugins/catwalk/i18n/es.json
new file mode 100644
index 0000000..873824e
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/es.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Umbral actual: {value}%",
+ "hideBackground": {
+ "description": "Ocultar el fondo del widget de escritorio",
+ "label": "Ocultar fondo"
+ },
+ "minimumThreshold": {
+ "description": "El uso de CPU debe estar por encima de este porcentaje para que el gato empiece a correr",
+ "label": "Umbral mÃnimo de CPU"
+ },
+ "title": "Configuración de Catwalk"
+ },
+ "tooltip": {
+ "running": "El gato está corriendo",
+ "sleeping": "El gato está durmiendo"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/fr.json b/.config/noctalia/plugins/catwalk/i18n/fr.json
new file mode 100644
index 0000000..1b5be2f
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/fr.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU : {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Seuil actuel : {value}%",
+ "hideBackground": {
+ "description": "Masquer l'arrière-plan du widget de bureau",
+ "label": "Masquer l'arrière-plan"
+ },
+ "minimumThreshold": {
+ "description": "L'utilisation du CPU doit être supérieure à ce pourcentage pour que le chat commence à courir",
+ "label": "Seuil minimum CPU"
+ },
+ "title": "Paramètres Catwalk"
+ },
+ "tooltip": {
+ "running": "Le chat court",
+ "sleeping": "Le chat dort"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/hu.json b/.config/noctalia/plugins/catwalk/i18n/hu.json
new file mode 100644
index 0000000..6de0c91
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/hu.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Sétasáv"
+ },
+ "settings": {
+ "currentThreshold": "Aktuális küszöbérték: {value}%",
+ "hideBackground": {
+ "description": "Asztali widget hátterének elrejtése",
+ "label": "Háttér elrejtése"
+ },
+ "minimumThreshold": {
+ "description": "A processzorhasználatnak e fölött a százalék felett kell lennie, hogy a macska futni kezdjen",
+ "label": "Minimum CPU Küszöbérték"
+ },
+ "title": "Catwalk beállÃtások"
+ },
+ "tooltip": {
+ "running": "A Noctalia fut.",
+ "sleeping": "A macska alszik"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/it.json b/.config/noctalia/plugins/catwalk/i18n/it.json
new file mode 100644
index 0000000..46d374e
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/it.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Soglia attuale: {value}%",
+ "hideBackground": {
+ "description": "Nascondi lo sfondo del widget desktop",
+ "label": "Nascondi sfondo"
+ },
+ "minimumThreshold": {
+ "description": "L'utilizzo della CPU deve essere superiore a questa percentuale affinché il gatto inizi a correre",
+ "label": "Soglia minima CPU"
+ },
+ "title": "Impostazioni Catwalk"
+ },
+ "tooltip": {
+ "running": "Il gatto sta correndo",
+ "sleeping": "Il gatto sta dormendo"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/ja.json b/.config/noctalia/plugins/catwalk/i18n/ja.json
new file mode 100644
index 0000000..93dbf46
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/ja.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "ç¾åœ¨ã®ã—ãã„値: {value}%",
+ "hideBackground": {
+ "description": "デスクトップウィジェットã®èƒŒæ™¯ã‚’éš ã™",
+ "label": "èƒŒæ™¯ã‚’éš ã™"
+ },
+ "minimumThreshold": {
+ "description": "猫ãŒèµ°ã‚Šå§‹ã‚ã‚‹ã«ã¯CPU使用率ãŒã“ã®å‰²åˆã‚’è¶…ãˆã¦ã„ã‚‹å¿…è¦ãŒã‚りã¾ã™",
+ "label": "最å°CPUã—ãã„値"
+ },
+ "title": "Catwalkè¨å®š"
+ },
+ "tooltip": {
+ "running": "猫ãŒèµ°ã£ã¦ã„ã¾ã™",
+ "sleeping": "猫ãŒå¯ã¦ã„ã¾ã™"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/ku.json b/.config/noctalia/plugins/catwalk/i18n/ku.json
new file mode 100644
index 0000000..e5bc9d5
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/ku.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Rêwînga pisîkan"
+ },
+ "settings": {
+ "currentThreshold": "Astana niha: {value}%",
+ "hideBackground": {
+ "description": "Paşxana wîceta sermaseyê veşêre",
+ "label": "Veşartina Paşxanê"
+ },
+ "minimumThreshold": {
+ "description": "Divê bikaranîna CPU ji vê rêjeyê bilindtir be da ku pisîk dest bi bezê bike",
+ "label": "Sînorê herî kêm ê CPU"
+ },
+ "title": "Mîhenên Catwalkê"
+ },
+ "tooltip": {
+ "running": "Pisîk direve",
+ "sleeping": "Pisîk radizê."
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/nl.json b/.config/noctalia/plugins/catwalk/i18n/nl.json
new file mode 100644
index 0000000..efb83a6
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/nl.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Huidige drempelwaarde: {value}%",
+ "hideBackground": {
+ "description": "Verberg de achtergrond van de bureaubladwidget",
+ "label": "Achtergrond verbergen"
+ },
+ "minimumThreshold": {
+ "description": "CPU-gebruik moet boven dit percentage liggen voordat de kat begint te rennen.",
+ "label": "Minimale CPU-drempel"
+ },
+ "title": "Catwalk Instellingen"
+ },
+ "tooltip": {
+ "running": "Kat rent",
+ "sleeping": "Kat slaapt"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/pl.json b/.config/noctalia/plugins/catwalk/i18n/pl.json
new file mode 100644
index 0000000..558416a
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/pl.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Wybieg"
+ },
+ "settings": {
+ "currentThreshold": "Aktualny próg: {value}%",
+ "hideBackground": {
+ "description": "Ukryj tło widżetu pulpitu",
+ "label": "Ukryj tło"
+ },
+ "minimumThreshold": {
+ "description": "Użycie CPU musi przekraczać ten procent, aby kot zaczął biegać",
+ "label": "Minimalny próg użycia CPU"
+ },
+ "title": "Ustawienia Catwalk"
+ },
+ "tooltip": {
+ "running": "Kot jest uruchomiony",
+ "sleeping": "Kot śpi"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/pt.json b/.config/noctalia/plugins/catwalk/i18n/pt.json
new file mode 100644
index 0000000..0efb112
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/pt.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Limite atual: {value}%",
+ "hideBackground": {
+ "description": "Ocultar o fundo do widget da área de trabalho",
+ "label": "Ocultar fundo"
+ },
+ "minimumThreshold": {
+ "description": "O uso da CPU deve estar acima desta porcentagem para o gato começar a correr",
+ "label": "Limite mÃnimo de CPU"
+ },
+ "title": "Configurações do Catwalk"
+ },
+ "tooltip": {
+ "running": "O gato está correndo",
+ "sleeping": "O gato está dormindo"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/ru.json b/.config/noctalia/plugins/catwalk/i18n/ru.json
new file mode 100644
index 0000000..53ef758
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/ru.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Catwalk"
+ },
+ "settings": {
+ "currentThreshold": "Текущий порог: {value}%",
+ "hideBackground": {
+ "description": "Скрыть фон виджета рабочего Ñтола",
+ "label": "Скрыть фон"
+ },
+ "minimumThreshold": {
+ "description": "ИÑпользование CPU должно быть выше Ñтого процента, чтобы кот начал бежать",
+ "label": "Минимальный порог CPU"
+ },
+ "title": "ÐаÑтройки Catwalk"
+ },
+ "tooltip": {
+ "running": "Кот бежит",
+ "sleeping": "Кот Ñпит"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/tr.json b/.config/noctalia/plugins/catwalk/i18n/tr.json
new file mode 100644
index 0000000..95bee2e
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/tr.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU: {usage}%",
+ "title": "Podyum"
+ },
+ "settings": {
+ "currentThreshold": "Mevcut eÅŸik: {value}%",
+ "hideBackground": {
+ "description": "Masaüstü bileşeninin arka planını gizle",
+ "label": "Arka planı gizle"
+ },
+ "minimumThreshold": {
+ "description": "CPU kullanımı, kedinin koşmaya başlaması için bu yüzdeden yüksek olmalıdır.",
+ "label": "Minimum CPU EÅŸiÄŸi"
+ },
+ "title": "Podyum Ayarları"
+ },
+ "tooltip": {
+ "running": "Kedi koÅŸuyor.",
+ "sleeping": "Kedi uyuyor."
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/uk-UA.json b/.config/noctalia/plugins/catwalk/i18n/uk-UA.json
new file mode 100644
index 0000000..e109beb
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/uk-UA.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "ЦП: {usage}%",
+ "title": "Подіум"
+ },
+ "settings": {
+ "currentThreshold": "Поточний поріг: {value}%",
+ "hideBackground": {
+ "description": "Приховати фон віджета робочого Ñтолу",
+ "label": "Приховати фон"
+ },
+ "minimumThreshold": {
+ "description": "ВикориÑÑ‚Ð°Ð½Ð½Ñ Ð¦ÐŸ має бути вище цього відÑотка, щоб кіт почав бігати.",
+ "label": "Мінімальний поріг ЦП"
+ },
+ "title": "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾Ð´Ñ–ÑƒÐ¼Ñƒ"
+ },
+ "tooltip": {
+ "running": "Кіт біжить",
+ "sleeping": "Кіт Ñпить"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/i18n/zh-CN.json b/.config/noctalia/plugins/catwalk/i18n/zh-CN.json
new file mode 100644
index 0000000..37a9709
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/i18n/zh-CN.json
@@ -0,0 +1,22 @@
+{
+ "panel": {
+ "cpuLabel": "CPU:{usage}%",
+ "title": "猫æ¥"
+ },
+ "settings": {
+ "currentThreshold": "当å‰é˜ˆå€¼ï¼š{value}%",
+ "hideBackground": {
+ "description": "éšè—桌é¢å°éƒ¨ä»¶çš„背景",
+ "label": "éšè—背景"
+ },
+ "minimumThreshold": {
+ "description": "CPU 使用率必须高于æ¤ç™¾åˆ†æ¯”,猫æ‰èƒ½å¼€å§‹è·‘动。",
+ "label": "最低CPU阈值"
+ },
+ "title": "时装秀场布置"
+ },
+ "tooltip": {
+ "running": "猫在跑",
+ "sleeping": "猫在ç¡è§‰"
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/icons/my-active-0-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-active-0-symbolic.svg
new file mode 100644
index 0000000..7abf1a0
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-active-0-symbolic.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-active-1-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-active-1-symbolic.svg
new file mode 100644
index 0000000..3a06046
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-active-1-symbolic.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-active-2-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-active-2-symbolic.svg
new file mode 100644
index 0000000..769737c
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-active-2-symbolic.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-active-3-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-active-3-symbolic.svg
new file mode 100644
index 0000000..79ec50c
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-active-3-symbolic.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-active-4-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-active-4-symbolic.svg
new file mode 100644
index 0000000..a3a2623
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-active-4-symbolic.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-idle-0-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-idle-0-symbolic.svg
new file mode 100644
index 0000000..e6e5028
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-idle-0-symbolic.svg
@@ -0,0 +1,21 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-idle-1-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-idle-1-symbolic.svg
new file mode 100644
index 0000000..ef0831c
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-idle-1-symbolic.svg
@@ -0,0 +1,21 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-idle-2-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-idle-2-symbolic.svg
new file mode 100644
index 0000000..b0b3e2a
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-idle-2-symbolic.svg
@@ -0,0 +1,21 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/icons/my-idle-3-symbolic.svg b/.config/noctalia/plugins/catwalk/icons/my-idle-3-symbolic.svg
new file mode 100644
index 0000000..af78795
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/icons/my-idle-3-symbolic.svg
@@ -0,0 +1,21 @@
+
+
diff --git a/.config/noctalia/plugins/catwalk/manifest.json b/.config/noctalia/plugins/catwalk/manifest.json
new file mode 100644
index 0000000..74e5518
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/manifest.json
@@ -0,0 +1,32 @@
+{
+ "id": "catwalk",
+ "name": "Catwalk",
+ "version": "1.1.7",
+ "minNoctaliaVersion": "3.6.0",
+ "author": "MannuVilasara",
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "A cute animated cat for your bar.",
+ "tags": [
+ "Bar",
+ "Desktop",
+ "Panel",
+ "Fun"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "desktopWidget": "DesktopWidget.qml",
+ "panel": "Panel.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {
+ "minimumThreshold": 10,
+ "hideBackground": false
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/catwalk/settings.json b/.config/noctalia/plugins/catwalk/settings.json
new file mode 100644
index 0000000..181fb4d
--- /dev/null
+++ b/.config/noctalia/plugins/catwalk/settings.json
@@ -0,0 +1,4 @@
+{
+ "minimumThreshold": 10,
+ "hideBackground": false
+}
diff --git a/.config/noctalia/plugins/dns-switcher/BarWidget.qml b/.config/noctalia/plugins/dns-switcher/BarWidget.qml
new file mode 100644
index 0000000..8152628
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/BarWidget.qml
@@ -0,0 +1,79 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ readonly property real contentWidth: contentRow.implicitWidth + (Style.marginM * 2)
+ readonly property real contentHeight: Style.capsuleHeight
+
+ implicitWidth: contentWidth
+ implicitHeight: contentHeight
+
+ readonly property var providerIcons: ({
+ "google": "brand-google",
+ "cloudflare": "cloud",
+ "opendns": "world",
+ "adguard": "shield-check",
+ "quad9": "lock"
+ })
+
+ Rectangle {
+ id: visualCapsule
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+ width: root.contentWidth
+ height: root.contentHeight
+ color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor
+ radius: height / 2
+ border.color: Style.capsuleBorderColor
+ border.width: Style.capsuleBorderWidth
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ RowLayout {
+ id: contentRow
+ anchors.centerIn: parent
+ spacing: Style.marginS
+
+ NIcon {
+ icon: root.providerIcons[mainInstance?.activeProviderId || ""] || "globe"
+ pointSize: Style.fontSizeS
+ color: Color.mPrimary
+ }
+
+ NText {
+ text: mainInstance?.currentDnsName || pluginApi?.tr("plugin.short_title") || "DNS"
+ color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
+ pointSize: Style.barFontSize
+ font.weight: Font.Medium
+ }
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ onClicked: {
+ if (pluginApi) {
+ pluginApi.openPanel(root.screen, root);
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/ControlCenterWidget.qml b/.config/noctalia/plugins/dns-switcher/ControlCenterWidget.qml
new file mode 100644
index 0000000..6ee14c9
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/ControlCenterWidget.qml
@@ -0,0 +1,35 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Widgets
+
+NIconButton {
+ id: root
+
+ property var pluginApi: null
+ property var screen: null
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ readonly property var providerIcons: ({
+ "google": "brand-google",
+ "cloudflare": "cloud",
+ "opendns": "world",
+ "adguard": "shield-check",
+ "quad9": "lock"
+ })
+
+ icon: providerIcons[mainInstance?.activeProviderId || ""] || "globe"
+
+ property bool isActive: mainInstance?.isCustomDns || false
+
+ colorBg: isActive ? Color.mPrimary : Color.mSurfaceVariant
+ colorFg: isActive ? Color.mOnPrimary : Color.mOnSurface
+
+ tooltipText: mainInstance?.currentDnsName || pluginApi?.tr("plugin.title") || "DNS Switcher"
+
+ onClicked: {
+ if (pluginApi) {
+ pluginApi.openPanel(root.screen, root);
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/Main.qml b/.config/noctalia/plugins/dns-switcher/Main.qml
new file mode 100644
index 0000000..5c90803
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/Main.qml
@@ -0,0 +1,231 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.System
+import qs.Services.UI
+
+Item {
+ id: root
+ property var pluginApi: null
+
+ property string currentDnsIp: ""
+ property string currentDnsName: pluginApi?.tr("status.checking") || "Checking..."
+ property string activeProviderId: ""
+ property bool isCustomDns: false
+ property bool isChanging: false
+ property string lastError: ""
+
+ readonly property var defaultProviders: [
+ { id: "google", label: "Google", ip: "8.8.8.8 8.8.4.4" },
+ { id: "cloudflare", label: "Cloudflare", ip: "1.1.1.1 1.0.0.1" },
+ { id: "opendns", label: "OpenDNS", ip: "208.67.222.222 208.67.220.220" },
+ { id: "adguard", label: "AdGuard", ip: "94.140.14.14 94.140.15.15" },
+ { id: "quad9", label: "Quad9", ip: "9.9.9.9 149.112.112.112" }
+ ]
+ property var customProviders: []
+
+ readonly property var ipLookup: ({
+ "8.8.8.8": "google", "8.8.4.4": "google",
+ "1.1.1.1": "cloudflare", "1.0.0.1": "cloudflare",
+ "208.67.222.222": "opendns", "208.67.220.220": "opendns",
+ "94.140.14.14": "adguard", "94.140.15.15": "adguard",
+ "9.9.9.9": "quad9", "149.112.112.112": "quad9"
+ })
+
+ readonly property var idToLabel: ({
+ "google": "Google",
+ "cloudflare": "Cloudflare",
+ "opendns": "OpenDNS",
+ "adguard": "AdGuard",
+ "quad9": "Quad9"
+ })
+
+ readonly property var ipRegex: /^(\d{1,3}\.){3}\d{1,3}$/
+
+ function isValidIp(ip) {
+ if (!ipRegex.test(ip)) return false;
+ var parts = ip.split(".");
+ for (var i = 0; i < parts.length; i++) {
+ var num = parseInt(parts[i], 10);
+ if (num < 0 || num > 255) return false;
+ }
+ return true;
+ }
+
+ function validateDnsInput(ipString) {
+ var trimmed = ipString.trim();
+ var parts = trimmed.split(/\s+/);
+ if (parts.length < 1 || parts.length > 2) return false;
+ for (var i = 0; i < parts.length; i++) {
+ if (!isValidIp(parts[i])) return false;
+ }
+ return true;
+ }
+
+ function loadCustomServers() {
+ if (!pluginApi) return;
+ try {
+ var json = pluginApi.pluginSettings.savedServers || "[]";
+ customProviders = JSON.parse(json);
+ } catch (e) {
+ customProviders = [];
+ }
+ }
+
+ function addCustomServer(name, ip) {
+ var trimmedName = (name || "").trim();
+ var trimmedIp = (ip || "").trim();
+
+ if (!trimmedName || !trimmedIp) return false;
+ if (!validateDnsInput(trimmedIp)) return false;
+
+ var list = customProviders.slice();
+ list.push({ label: trimmedName, ip: trimmedIp, isCustom: true });
+ customProviders = list;
+ saveCustomServers();
+ return true;
+ }
+
+ function removeCustomServer(index) {
+ if (index < 0 || index >= customProviders.length) return;
+ var list = customProviders.slice();
+ list.splice(index, 1);
+ customProviders = list;
+ saveCustomServers();
+ }
+
+ function saveCustomServers() {
+ if (pluginApi) {
+ pluginApi.pluginSettings.savedServers = JSON.stringify(customProviders);
+ pluginApi.saveSettings();
+ }
+ }
+
+ Component.onCompleted: loadCustomServers()
+
+ function updateDnsState(rawIp) {
+ var trimmed = rawIp.trim();
+ currentDnsIp = trimmed;
+
+ // Check default providers via exact match
+ var parts = trimmed.split(/[\s,]+/);
+ for (var i = 0; i < parts.length; i++) {
+ var id = ipLookup[parts[i]];
+ if (id) {
+ activeProviderId = id;
+ currentDnsName = idToLabel[id];
+ isCustomDns = true;
+ return;
+ }
+ }
+
+ // Check custom providers via exact match
+ for (var j = 0; j < customProviders.length; j++) {
+ var srv = customProviders[j];
+ var srvIps = srv.ip.split(/\s+/);
+ for (var k = 0; k < srvIps.length; k++) {
+ if (parts.indexOf(srvIps[k]) !== -1) {
+ activeProviderId = "custom_" + j;
+ currentDnsName = srv.label;
+ isCustomDns = true;
+ return;
+ }
+ }
+ }
+
+ // ISP default
+ if (trimmed === "" || trimmed.startsWith("192.168.") || trimmed.startsWith("127.")) {
+ activeProviderId = "default";
+ currentDnsName = pluginApi?.tr("status.default") || "Default (ISP)";
+ isCustomDns = false;
+ return;
+ }
+
+ // Unknown custom IP
+ activeProviderId = "unknown";
+ currentDnsName = pluginApi?.tr("status.custom", { ip: trimmed }) || "Custom (" + trimmed + ")";
+ isCustomDns = true;
+ }
+
+ Timer {
+ interval: 10000
+ running: true
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ if (!root.isChanging) {
+ checkProcess.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: checkProcess
+ command: ["sh", "-c", "nmcli -f IP4.DNS dev show | head -n 1 | awk '{print $2}'"]
+ stdout: StdioCollector {
+ onTextChanged: {
+ root.updateDnsState(text);
+ }
+ }
+ }
+
+ function setDns(payload) {
+ if (isChanging) return;
+
+ var dnsIp = payload;
+ var preset = defaultProviders.find(function(p) { return p.id === payload; });
+ if (preset) {
+ dnsIp = preset.ip;
+ }
+ if (payload === "default") {
+ dnsIp = "";
+ }
+
+ // Validate non-default IPs
+ if (dnsIp !== "" && !validateDnsInput(dnsIp)) {
+ console.warn("DNS Switcher: Invalid DNS IP rejected:", dnsIp);
+ lastError = pluginApi?.tr("error.invalid_ip") || "Invalid IP address";
+ return;
+ }
+
+ isChanging = true;
+ lastError = "";
+ currentDnsName = pluginApi?.tr("status.switching") || "Switching...";
+
+ // Sanitize: escape any remaining special characters as a safety net
+ var safeIp = dnsIp.replace(/[^0-9. ]/g, "");
+
+ var cmd;
+ if (safeIp === "") {
+ cmd = "CON=$(nmcli -t -f NAME connection show --active | head -n 1); " +
+ "if [ -z \"$CON\" ]; then exit 1; fi; " +
+ "nmcli con mod \"$CON\" ipv4.dns \"\" ipv4.ignore-auto-dns no; " +
+ "nmcli con up \"$CON\"";
+ } else {
+ cmd = "CON=$(nmcli -t -f NAME connection show --active | head -n 1); " +
+ "if [ -z \"$CON\" ]; then exit 1; fi; " +
+ "nmcli con mod \"$CON\" ipv4.dns \"" + safeIp + "\" ipv4.ignore-auto-dns yes; " +
+ "nmcli con up \"$CON\"";
+ }
+
+ changeProcess.command = ["pkexec", "sh", "-c", cmd];
+ changeProcess.running = true;
+ }
+
+ Process {
+ id: changeProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {
+ id: changeStderr
+ }
+ onExited: function(code) {
+ root.isChanging = false;
+ if (code !== 0) {
+ root.lastError = pluginApi?.tr("error.apply_failed") || "Failed to apply DNS";
+ console.warn("DNS Switcher: Apply failed with code", code, changeStderr.text);
+ }
+ checkProcess.running = true;
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/Panel.qml b/.config/noctalia/plugins/dns-switcher/Panel.qml
new file mode 100644
index 0000000..ea3d5b5
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/Panel.qml
@@ -0,0 +1,335 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ property real contentPreferredWidth: Math.round(360 * Style.uiScaleRatio)
+ property real contentPreferredHeight: mainLayout.implicitHeight + (Style.marginL * 2)
+
+ readonly property var geometryPlaceholder: bg
+ readonly property bool allowAttach: true
+
+ // State for Adding
+ property string newName: ""
+ property string newIp: ""
+ property bool isAdding: false
+ property string validationError: ""
+
+ Rectangle {
+ id: bg
+ anchors.fill: parent
+ color: Color.mSurface
+ radius: Style.radiusL
+ border.color: Qt.alpha(Color.mOutline, 0.2)
+ border.width: 1
+
+ ColumnLayout {
+ id: mainLayout
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: Style.marginM
+
+ NBox {
+ Layout.fillWidth: true
+ Layout.preferredHeight: headerRow.implicitHeight + Style.marginM
+
+ RowLayout {
+ id: headerRow
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "globe"
+ pointSize: Style.fontSizeL
+ color: Color.mPrimary
+ }
+
+ NText {
+ text: pluginApi?.tr("plugin.title") || "DNS Switcher"
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: mainInstance?.currentDnsName || "..."
+ pointSize: Style.fontSizeS
+ color: (mainInstance?.isChanging) ? Color.mPrimary : Color.mSecondary
+ font.weight: Font.Medium
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: errorText.implicitHeight + Style.marginM
+ visible: (mainInstance?.lastError || "") !== ""
+ color: Qt.alpha(Color.mError, 0.1)
+ radius: Style.radiusS
+ border.color: Qt.alpha(Color.mError, 0.3)
+ border.width: 1
+
+ NText {
+ id: errorText
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ text: mainInstance?.lastError || ""
+ color: Color.mError
+ pointSize: Style.fontSizeS
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ NScrollView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.round(240 * Style.uiScaleRatio)
+ clip: true
+
+ ColumnLayout {
+ width: parent.width
+ spacing: Style.marginS
+
+ component DnsOption: Rectangle {
+ id: opt
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.round(50 * Style.uiScaleRatio)
+ radius: Style.radiusM
+
+ property string label: ""
+ property string providerId: ""
+ property string providerIp: ""
+ property bool isCustom: false
+ property int customIndex: -1
+ property bool isActive: (mainInstance?.activeProviderId || "") === providerId
+ property bool isDisabled: mainInstance?.isChanging || false
+
+ color: isActive ? Color.mPrimary : Color.mSurfaceVariant
+ opacity: isDisabled ? 0.6 : 1.0
+ Behavior on color { ColorAnimation { duration: Style.animationFast } }
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: opt.radius
+ color: (hoverArea.containsMouse && !opt.isActive && !opt.isDisabled)
+ ? Color.mHover : "transparent"
+ opacity: (hoverArea.containsMouse && !opt.isActive && !opt.isDisabled)
+ ? 0.2 : 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginM
+ spacing: Style.marginM
+
+ NIcon {
+ icon: opt.isActive ? "check" : "circle"
+ pointSize: Style.fontSizeM
+ color: opt.isActive ? Color.mOnPrimary : Color.mOnSurfaceVariant
+ }
+
+ NText {
+ text: opt.label
+ pointSize: Style.fontSizeM
+ font.weight: Font.Medium
+ color: opt.isActive ? Color.mOnPrimary : Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: opt.providerIp
+ pointSize: Style.fontSizeXS
+ color: opt.isActive
+ ? Qt.alpha(Color.mOnPrimary, 0.7)
+ : Color.mOnSurfaceVariant
+ visible: opt.providerIp !== ""
+ }
+
+ NIconButton {
+ visible: opt.isCustom
+ icon: "trash"
+ colorFg: opt.isActive ? Color.mOnPrimary : Color.mError
+ enabled: !opt.isDisabled
+ onClicked: {
+ if (mainInstance && opt.customIndex >= 0) {
+ mainInstance.removeCustomServer(opt.customIndex);
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: hoverArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: opt.isDisabled ? Qt.ForbiddenCursor : Qt.PointingHandCursor
+ onClicked: {
+ if (opt.isDisabled) return;
+ if (mainInstance) {
+ mainInstance.setDns(opt.isCustom ? opt.providerIp : opt.providerId);
+ }
+ if (pluginApi) {
+ pluginApi.withCurrentScreen(function(s) {
+ pluginApi.closePanel(s);
+ });
+ }
+ }
+ }
+ }
+
+ DnsOption {
+ label: "Google"
+ providerId: "google"
+ providerIp: "8.8.8.8 8.8.4.4"
+ }
+ DnsOption {
+ label: "Cloudflare"
+ providerId: "cloudflare"
+ providerIp: "1.1.1.1 1.0.0.1"
+ }
+ DnsOption {
+ label: "OpenDNS"
+ providerId: "opendns"
+ providerIp: "208.67.222.222 208.67.220.220"
+ }
+ DnsOption {
+ label: "AdGuard"
+ providerId: "adguard"
+ providerIp: "94.140.14.14 94.140.15.15"
+ }
+ DnsOption {
+ label: "Quad9"
+ providerId: "quad9"
+ providerIp: "9.9.9.9 149.112.112.112"
+ }
+
+ Repeater {
+ model: mainInstance ? mainInstance.customProviders : []
+ delegate: DnsOption {
+ label: modelData.label
+ providerId: "custom_" + index
+ providerIp: modelData.ip
+ isCustom: true
+ customIndex: index
+ }
+ }
+ }
+ }
+
+ NButton {
+ Layout.fillWidth: true
+ text: root.isAdding
+ ? (pluginApi?.tr("panel.cancel") || "Cancel")
+ : (pluginApi?.tr("panel.add_server") || "Add Custom Server")
+ icon: root.isAdding ? "x" : "plus"
+ backgroundColor: root.isAdding ? Color.mSurfaceVariant : Qt.alpha(Color.mPrimary, 0.15)
+ textColor: root.isAdding ? Color.mOnSurface : Color.mPrimary
+ enabled: !(mainInstance?.isChanging || false)
+ onClicked: {
+ root.isAdding = !root.isAdding;
+ if (!root.isAdding) {
+ root.newName = "";
+ root.newIp = "";
+ root.validationError = "";
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ visible: root.isAdding
+ spacing: Style.marginS
+
+ RowLayout {
+ spacing: Style.marginS
+
+ NTextInput {
+ id: nameInput
+ Layout.fillWidth: true
+ label: pluginApi?.tr("panel.name_placeholder") || "Name"
+ placeholderText: pluginApi?.tr("panel.name_placeholder") || "e.g. My DNS"
+ text: root.newName
+ onTextChanged: {
+ root.newName = text;
+ root.validationError = "";
+ }
+ }
+
+ NTextInput {
+ id: ipInput
+ Layout.fillWidth: true
+ label: pluginApi?.tr("panel.ip_placeholder") || "IP Address"
+ placeholderText: pluginApi?.tr("panel.ip_placeholder") || "e.g. 1.2.3.4 5.6.7.8"
+ text: root.newIp
+ onTextChanged: {
+ root.newIp = text;
+ root.validationError = "";
+ }
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: root.validationError !== ""
+ text: root.validationError
+ color: Color.mError
+ pointSize: Style.fontSizeXS
+ wrapMode: Text.WordWrap
+ }
+
+ NButton {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("panel.save") || "Save"
+ icon: "check"
+ enabled: root.newName.trim() !== "" && root.newIp.trim() !== ""
+ onClicked: {
+ if (mainInstance) {
+ var success = mainInstance.addCustomServer(root.newName, root.newIp);
+ if (success) {
+ root.newName = "";
+ root.newIp = "";
+ root.isAdding = false;
+ root.validationError = "";
+ } else {
+ root.validationError = pluginApi?.tr("error.invalid_ip")
+ || "Invalid IP address format. Use: x.x.x.x or x.x.x.x x.x.x.x";
+ }
+ }
+ }
+ }
+ }
+
+ NButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginS
+ text: pluginApi?.tr("panel.reset") || "Reset to Default (ISP)"
+ icon: "refresh"
+ backgroundColor: Qt.alpha(Color.mError, 0.15)
+ textColor: Color.mError
+ enabled: !(mainInstance?.isChanging || false)
+ && (mainInstance?.isCustomDns || false)
+ onClicked: {
+ if (mainInstance) {
+ mainInstance.setDns("default");
+ }
+ if (pluginApi) {
+ pluginApi.withCurrentScreen(function(s) {
+ pluginApi.closePanel(s);
+ });
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/README.md b/.config/noctalia/plugins/dns-switcher/README.md
new file mode 100644
index 0000000..7d2e3aa
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/README.md
@@ -0,0 +1,69 @@
+# DNS Switcher
+
+A plugin that allows you to quickly switch between different DNS servers directly from Noctalia, with support for custom DNS providers.
+
+## Features
+
+- **Quick DNS Switching**: Switch between popular DNS providers with a single click
+- **Pre-configured Providers**: Includes Google, Cloudflare, OpenDNS, AdGuard, and Quad9
+- **Custom DNS Servers**: Add and manage your own custom DNS servers
+- **Real-time Status**: Displays the current active DNS server in the bar and control center
+- **Auto-detection**: Automatically detects which DNS provider is currently active
+- **NetworkManager Integration**: Uses NetworkManager (nmcli) to manage DNS settings
+
+## Usage
+
+1. Click on the DNS widget in the top bar or control center
+2. Select a DNS provider from the list
+3. The DNS will be changed immediately (requires password for privilege escalation)
+4. The current DNS status is displayed in the widget
+
+### Pre-configured DNS Providers
+
+- **Google**: `8.8.8.8` and `8.8.4.4`
+- **Cloudflare**: `1.1.1.1` and `1.0.0.1`
+- **OpenDNS**: `208.67.222.222` and `208.67.220.220`
+- **AdGuard**: `94.140.14.14` and `94.140.15.15`
+- **Quad9**: `9.9.9.9` and `149.112.112.112`
+
+### Adding Custom DNS Servers
+
+1. Open the DNS Switcher panel
+2. Click "Add Custom Server"
+3. Enter a name for your DNS server
+4. Enter the IP address(es) (space-separated for multiple IPs)
+5. Click "Save"
+6. Your custom server will appear in the list and can be selected like any other provider
+
+### Resetting to Default
+
+Click the "Reset to Default (ISP)" button to restore your system's default DNS settings (usually provided by your ISP).
+
+## Widgets
+
+- **Bar Widget**: Displays the current DNS provider name with an icon in the top bar
+- **Control Center Widget**: Shows the DNS status as an icon button in the control center
+- **Panel**: Full panel interface for managing DNS providers and custom servers
+
+## Requirements
+
+- **Noctalia 4.0.0 or later**
+- **NetworkManager**: The plugin uses `nmcli` to manage DNS settings
+- **pkexec**: Required for privilege escalation to change system DNS settings
+- **Active Network Connection**: Must have an active NetworkManager connection
+
+## Technical Details
+
+The plugin:
+- Checks the current DNS every 3 seconds
+- Uses NetworkManager's connection modification to change DNS settings
+- Requires root privileges (via `pkexec`) to modify network settings
+- Automatically restarts the network connection after DNS changes
+- Supports both IPv4 DNS configuration
+
+## Notes
+
+- DNS changes require administrator privileges, so you'll be prompted for your password
+- The plugin only modifies the currently active NetworkManager connection
+- Custom DNS servers are saved in the plugin settings and persist across restarts
+- If no active connection is found, the DNS change will fail
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/de.json b/.config/noctalia/plugins/dns-switcher/i18n/de.json
new file mode 100644
index 0000000..7e05260
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/de.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS-Wechsler",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Prüfen...",
+ "switching": "Wechseln...",
+ "default": "Standard (ISP)",
+ "custom": "Benutzerdefiniert ({ip})",
+ "unknown": "Unbekannt"
+ },
+ "panel": {
+ "add_server": "Server hinzufügen",
+ "cancel": "Abbrechen",
+ "save": "Speichern",
+ "reset": "Zurücksetzen (ISP)",
+ "name_placeholder": "Name",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/en.json b/.config/noctalia/plugins/dns-switcher/i18n/en.json
new file mode 100644
index 0000000..8fc6800
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/en.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS Switcher",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Checking...",
+ "switching": "Switching...",
+ "default": "Default (ISP)",
+ "custom": "Custom ({ip})",
+ "unknown": "Unknown"
+ },
+ "panel": {
+ "add_server": "Add Custom Server",
+ "cancel": "Cancel",
+ "save": "Save",
+ "reset": "Reset to Default (ISP)",
+ "name_placeholder": "Name",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/es.json b/.config/noctalia/plugins/dns-switcher/i18n/es.json
new file mode 100644
index 0000000..58ac239
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/es.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Cambiador de DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Comprobando...",
+ "switching": "Cambiando...",
+ "default": "Predeterminado (ISP)",
+ "custom": "Personalizado ({ip})",
+ "unknown": "Desconocido"
+ },
+ "panel": {
+ "add_server": "Añadir Servidor",
+ "cancel": "Cancelar",
+ "save": "Guardar",
+ "reset": "Restablecer a ISP",
+ "name_placeholder": "Nombre",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/fr.json b/.config/noctalia/plugins/dns-switcher/i18n/fr.json
new file mode 100644
index 0000000..7c82e8c
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/fr.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Changeur DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Vérification...",
+ "switching": "Changement...",
+ "default": "Par défaut (FAI)",
+ "custom": "Personnalisé ({ip})",
+ "unknown": "Inconnu"
+ },
+ "panel": {
+ "add_server": "Ajouter un serveur",
+ "cancel": "Annuler",
+ "save": "Enregistrer",
+ "reset": "Réinitialiser (FAI)",
+ "name_placeholder": "Nom",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/hu.json b/.config/noctalia/plugins/dns-switcher/i18n/hu.json
new file mode 100644
index 0000000..5ee8a73
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/hu.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS Váltó",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Ellenőrzés...",
+ "switching": "Váltás...",
+ "default": "Alapértelmezett (ISP)",
+ "custom": "Egyéni ({ip})",
+ "unknown": "Ismeretlen"
+ },
+ "panel": {
+ "add_server": "Szerver Hozzáadása",
+ "cancel": "Mégse",
+ "save": "Mentés",
+ "reset": "VisszaállÃtás (ISP)",
+ "name_placeholder": "Név",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/it.json b/.config/noctalia/plugins/dns-switcher/i18n/it.json
new file mode 100644
index 0000000..5d75464
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/it.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Switcher DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Verifica in corso...",
+ "switching": "Cambio in corso...",
+ "default": "Predefinito (ISP)",
+ "custom": "Personalizzato ({ip})",
+ "unknown": "Sconosciuto"
+ },
+ "panel": {
+ "add_server": "Aggiungi Server",
+ "cancel": "Annulla",
+ "save": "Salva",
+ "reset": "Ripristina (ISP)",
+ "name_placeholder": "Nome",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/ja.json b/.config/noctalia/plugins/dns-switcher/i18n/ja.json
new file mode 100644
index 0000000..7d20770
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/ja.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS スイッãƒãƒ£ãƒ¼",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "確èªä¸...",
+ "switching": "切り替ãˆä¸...",
+ "default": "デフォルト (ISP)",
+ "custom": "カスタム({ip})",
+ "unknown": "䏿˜Ž"
+ },
+ "panel": {
+ "add_server": "サーãƒãƒ¼ã‚’è¿½åŠ ",
+ "cancel": "ã‚ャンセル",
+ "save": "ä¿å˜",
+ "reset": "リセット (ISP)",
+ "name_placeholder": "åå‰",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/ku.json b/.config/noctalia/plugins/dns-switcher/i18n/ku.json
new file mode 100644
index 0000000..5a369be
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/ku.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Guhertoka DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Kontrol dike...",
+ "switching": "Diguhere...",
+ "default": "Standard (ISP)",
+ "custom": "Taybet ({ip})",
+ "unknown": "Nenas"
+ },
+ "panel": {
+ "add_server": "Pêşkêşkar Zêde Bike",
+ "cancel": "Betal Bike",
+ "save": "Tomar Bike",
+ "reset": "Vegere Standard (ISP)",
+ "name_placeholder": "Nav",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/nl.json b/.config/noctalia/plugins/dns-switcher/i18n/nl.json
new file mode 100644
index 0000000..03817d5
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/nl.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS Wisselaar",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Controleren...",
+ "switching": "Wisselen...",
+ "default": "Standaard (ISP)",
+ "custom": "Aangepast ({ip})",
+ "unknown": "Onbekend"
+ },
+ "panel": {
+ "add_server": "Server Toevoegen",
+ "cancel": "Annuleren",
+ "save": "Opslaan",
+ "reset": "Herstellen (ISP)",
+ "name_placeholder": "Naam",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/pl.json b/.config/noctalia/plugins/dns-switcher/i18n/pl.json
new file mode 100644
index 0000000..fb807a9
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/pl.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Przełącznik DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Sprawdzanie...",
+ "switching": "Przełączanie...",
+ "default": "Domyślny (ISP)",
+ "custom": "Własny ({ip})",
+ "unknown": "Nieznany"
+ },
+ "panel": {
+ "add_server": "Dodaj Serwer",
+ "cancel": "Anuluj",
+ "save": "Zapisz",
+ "reset": "Przywróć Domyślne (ISP)",
+ "name_placeholder": "Nazwa",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/pt.json b/.config/noctalia/plugins/dns-switcher/i18n/pt.json
new file mode 100644
index 0000000..08bd4c5
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/pt.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Alternador de DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Verificando...",
+ "switching": "Alternando...",
+ "default": "Padrão (ISP)",
+ "custom": "Personalizado ({ip})",
+ "unknown": "Desconhecido"
+ },
+ "panel": {
+ "add_server": "Adicionar Servidor",
+ "cancel": "Cancelar",
+ "save": "Salvar",
+ "reset": "Restaurar Padrão (ISP)",
+ "name_placeholder": "Nome",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/ru.json b/.config/noctalia/plugins/dns-switcher/i18n/ru.json
new file mode 100644
index 0000000..5580309
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/ru.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Переключатель DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Проверка...",
+ "switching": "Переключение...",
+ "default": "По умолчанию (ISP)",
+ "custom": "Польз. ({ip})",
+ "unknown": "ÐеизвеÑтно"
+ },
+ "panel": {
+ "add_server": "Добавить Ñервер",
+ "cancel": "Отмена",
+ "save": "Сохранить",
+ "reset": "Ð¡Ð±Ñ€Ð¾Ñ (ISP)",
+ "name_placeholder": "Ðазвание",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/tr.json b/.config/noctalia/plugins/dns-switcher/i18n/tr.json
new file mode 100644
index 0000000..a17a35c
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/tr.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS DeÄŸiÅŸtirici",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Kontrol ediliyor...",
+ "switching": "DeÄŸiÅŸtiriliyor...",
+ "default": "Varsayılan (ISP)",
+ "custom": "Özel ({ip})",
+ "unknown": "Bilinmiyor"
+ },
+ "panel": {
+ "add_server": "Sunucu Ekle",
+ "cancel": "İptal",
+ "save": "Kaydet",
+ "reset": "Varsayılana Dön (ISP)",
+ "name_placeholder": "İsim",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/uk-UA.json b/.config/noctalia/plugins/dns-switcher/i18n/uk-UA.json
new file mode 100644
index 0000000..bfb0141
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/uk-UA.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "Перемикач DNS",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "Перевірка...",
+ "switching": "ПеремиканнÑ...",
+ "default": "За замовчуваннÑм (ISP)",
+ "custom": "ВлаÑний ({ip})",
+ "unknown": "Ðевідомо"
+ },
+ "panel": {
+ "add_server": "Додати Ñервер",
+ "cancel": "СкаÑувати",
+ "save": "Зберегти",
+ "reset": "Скинути (ISP)",
+ "name_placeholder": "Ðазва",
+ "ip_placeholder": "IP"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/i18n/zh-CN.json b/.config/noctalia/plugins/dns-switcher/i18n/zh-CN.json
new file mode 100644
index 0000000..ea28931
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/i18n/zh-CN.json
@@ -0,0 +1,21 @@
+{
+ "plugin": {
+ "title": "DNS 切æ¢å™¨",
+ "short_title": "DNS"
+ },
+ "status": {
+ "checking": "检查ä¸...",
+ "switching": "切æ¢ä¸...",
+ "default": "默认 (ISP)",
+ "custom": "自定义 ({ip})",
+ "unknown": "未知"
+ },
+ "panel": {
+ "add_server": "æ·»åŠ è‡ªå®šä¹‰æœåС噍",
+ "cancel": "å–æ¶ˆ",
+ "save": "ä¿å˜",
+ "reset": "é‡ç½®ä¸ºé»˜è®¤ (ISP)",
+ "name_placeholder": "åç§°",
+ "ip_placeholder": "IP地å€"
+ }
+}
diff --git a/.config/noctalia/plugins/dns-switcher/manifest.json b/.config/noctalia/plugins/dns-switcher/manifest.json
new file mode 100644
index 0000000..84fe27d
--- /dev/null
+++ b/.config/noctalia/plugins/dns-switcher/manifest.json
@@ -0,0 +1,20 @@
+{
+ "id": "dns-switcher",
+ "name": "DNS Switcher",
+ "version": "1.2.1",
+ "description": "DNS Manager with custom server support for Noctalia.",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "tags": [
+ "Bar",
+ "Network"
+ ],
+ "author": "Ronin-CK",
+ "license": "MIT",
+ "minNoctaliaVersion": "4.0.0",
+ "entryPoints": {
+ "main": "Main.qml",
+ "panel": "Panel.qml",
+ "barWidget": "BarWidget.qml",
+ "controlCenterWidget": "ControlCenterWidget.qml"
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/BarWidget.qml b/.config/noctalia/plugins/music-search/BarWidget.qml
new file mode 100644
index 0000000..43832f9
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/BarWidget.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.Bar.Extras
+import qs.Services.UI
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property string barPosition: Settings.data.bar.position || "top"
+ readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
+ readonly property bool activePlayback: mainInstance?.isPlaying === true || mainInstance?.playbackStarting === true
+ readonly property string trackTitle: (mainInstance?.currentTitle || "").trim()
+ readonly property string pillText: activePlayback && trackTitle.length > 0 ? trackTitle : ""
+ readonly property bool showHoverTrackTitle: pluginApi?.pluginSettings?.showBarHoverTrackTitle
+ ?? pluginApi?.manifest?.metadata?.defaultSettings?.showBarHoverTrackTitle
+ ?? true
+ readonly property string tooltipText: activePlayback
+ ? pluginApi?.tr("bar_widget.tooltipPlaying", {"title": trackTitle})
+ : pluginApi?.tr("bar_widget.tooltipIdle")
+
+ implicitWidth: pill.width
+ implicitHeight: pill.height
+
+ BarPill {
+ id: pill
+
+ screen: root.screen
+ oppositeDirection: BarService.getPillDirection(root)
+ icon: activePlayback ? (mainInstance?.isPaused === true ? "player-pause-filled" : "disc") : "music"
+ text: root.showHoverTrackTitle ? root.pillText : ""
+ autoHide: false
+ rotateText: root.isVerticalBar && root.showHoverTrackTitle && root.pillText.length > 0
+ tooltipText: root.tooltipText
+
+ onClicked: {
+ pluginApi?.togglePanel(root.screen, pill);
+ }
+
+ onRightClicked: {
+ PanelService.showContextMenu(contextMenu, pill, root.screen);
+ }
+ }
+
+ NPopupContextMenu {
+ id: contextMenu
+
+ model: [
+ {
+ "label": pluginApi?.tr("bar_widget.settings"),
+ "action": "widget-settings",
+ "icon": "settings",
+ "enabled": true
+ }
+ ]
+
+ onTriggered: action => {
+ contextMenu.close();
+ PanelService.closeContextMenu(root.screen);
+
+ if (action === "widget-settings") {
+ BarService.openPluginSettings(root.screen, pluginApi.manifest);
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/LauncherProvider.qml b/.config/noctalia/plugins/music-search/LauncherProvider.qml
new file mode 100644
index 0000000..8bbdf73
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/LauncherProvider.qml
@@ -0,0 +1,2901 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Media
+import "MusicUtils.js" as MusicUtils
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ property var launcher: null
+ property string name: pluginApi?.tr("common.music")
+ property bool handleSearch: false
+ property string supportedLayouts: "list"
+ property string iconMode: Settings.data.appLauncher.iconMode
+ property bool hasPreview: true
+ property bool previewNeedsGlobalToggle: false
+ property url previewComponentPath: Qt.resolvedUrl("MusicPreview.qml")
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property string helperPath: mainInstance?.helperPath || Qt.resolvedUrl("musicctl.sh").toString().replace("file://", "")
+ readonly property string commandName: ">" + (pluginApi?.manifest?.metadata?.commandPrefix || "music-search")
+
+ property string activeSearchQuery: ""
+ property string pendingSearchQuery: ""
+ property string lastCompletedQuery: ""
+ property bool searchBusy: false
+ property string searchError: ""
+ property var searchResults: []
+ property var previewDetailCache: ({})
+ property string runningSearchQuery: ""
+ property string runningSearchProvider: ""
+ property int searchEpoch: 0
+ property int runningSearchEpoch: 0
+ property bool pendingSearchRestart: false
+ property string playlistPickerEntryId: ""
+ property string playlistPickerEntryTitle: ""
+ property string playlistRenameId: ""
+ property string playlistRenameTitle: ""
+ property string tagEditorEntryId: ""
+ property string tagEditorEntryTitle: ""
+ property string metadataEditorEntryId: ""
+ property string metadataEditorEntryTitle: ""
+ property string metadataEditorField: ""
+
+ Process {
+ id: searchProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ var completedQuery = root.runningSearchQuery;
+ var staleSearch = root.runningSearchEpoch !== root.searchEpoch;
+
+ root.searchBusy = false;
+ root.searchError = "";
+
+ if (!staleSearch && exitCode === 0) {
+ try {
+ var parsed = JSON.parse(String(searchProcess.stdout.text || "[]"));
+ root.searchResults = Array.isArray(parsed) ? parsed : [];
+ root.lastCompletedQuery = completedQuery;
+ } catch (error) {
+ root.searchResults = [];
+ root.lastCompletedQuery = completedQuery;
+ root.searchError = pluginApi?.tr("errors.searchMalformed");
+ Logger.w("MusicSearchLauncher", "Failed to parse search results:", error);
+ }
+ } else if (!staleSearch) {
+ root.searchResults = [];
+ root.lastCompletedQuery = completedQuery;
+ root.searchError = String(searchProcess.stderr.text || "").trim() || pluginApi?.tr("search.failed");
+ }
+
+ root.runningSearchQuery = "";
+ root.runningSearchProvider = "";
+
+ if (root.pendingSearchQuery && (root.pendingSearchRestart || root.pendingSearchQuery !== completedQuery)) {
+ var nextQuery = root.pendingSearchQuery;
+ root.pendingSearchQuery = "";
+ root.pendingSearchRestart = false;
+ root.startSearch(nextQuery);
+ return;
+ }
+
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+ }
+
+ Connections {
+ target: mainInstance
+
+ function onIsPlayingChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onIsPausedChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onCurrentSortByChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onPlaylistEntriesChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onCurrentProviderChanged() {
+ root.searchEpoch += 1;
+ root.searchResults = [];
+ root.lastCompletedQuery = "";
+ root.searchError = "";
+ if (root.searchBusy && root.activeSearchQuery.length > 0) {
+ if (root.pendingSearchQuery.length === 0) {
+ root.pendingSearchQuery = root.activeSearchQuery;
+ }
+ root.pendingSearchRestart = true;
+ }
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onLibraryEntriesChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onLastErrorChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onLastNoticeChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+ }
+
+ Connections {
+ target: MediaService
+
+ function onTrackTitleChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+
+ function onPlayerIdentityChanged() {
+ if (launcher) {
+ launcher.updateResults();
+ }
+ }
+ }
+
+ function handleCommand(searchText) {
+ return searchText.startsWith(commandName);
+ }
+
+ function commands() {
+ return [
+ {
+ "name": commandName,
+ "description": pluginApi?.tr("command.description", {"provider": mainInstance?.providerLabel() || pluginApi?.tr("providers.youtube")}),
+ "icon": "music",
+ "isTablerIcon": true,
+ "isImage": false,
+ "onActivate": function () {
+ launcher.setSearchText(commandName + " ");
+ }
+ }
+ ];
+ }
+
+ function normalizeToken(value) {
+ return (value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
+ }
+
+ function looksLikeUrl(value) {
+ var trimmed = (value || "").trim();
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || /^www\./i.test(trimmed);
+ }
+
+ function parseSearchProviderQuery(query) {
+ var raw = (query || "");
+ var match = raw.match(/^(yt|youtube|sc|soundcloud|local):\s*(.*)$/i);
+ if (!match) {
+ return {
+ "provider": mainInstance?.currentProvider || "youtube",
+ "query": raw,
+ "explicit": false
+ };
+ }
+
+ var prefix = (match[1] || "").toLowerCase();
+ var provider = "youtube";
+ if (prefix === "sc" || prefix === "soundcloud") {
+ provider = "soundcloud";
+ } else if (prefix === "local") {
+ provider = "local";
+ }
+
+ return {
+ "provider": provider,
+ "query": (match[2] || "").trim(),
+ "explicit": true
+ };
+ }
+
+ function formatRating(rating) {
+ var r = Number(rating || 0);
+ if (r <= 0) return "";
+ var stars = "";
+ for (var i = 0; i < r; i++) stars += "\u2605";
+ return stars;
+ }
+
+ function formatPlayCount(count) {
+ var plays = Number(count || 0);
+ if (!isFinite(plays) || plays <= 0) {
+ return "";
+ }
+ return plays === 1 ? (pluginApi?.tr("common.onePlay")) : (pluginApi?.tr("common.plays", {"count": plays}));
+ }
+
+ function formatSpeedLabel(value) {
+ var speed = value ?? 1;
+ if (!isFinite(speed) || speed <= 0) {
+ speed = 1;
+ }
+ return pluginApi?.tr("speed.multiplier", {"speed": speed.toFixed(2)});
+ }
+
+ function buildDescription(entry, prefix) {
+ var parts = [];
+ if (prefix) {
+ parts.push(prefix);
+ }
+ if (mainInstance?.showUploaderMetadata !== false && entry.uploader) {
+ parts.push(entry.uploader);
+ }
+ if (mainInstance?.showAlbumMetadata !== false && entry.album) {
+ parts.push(entry.album);
+ }
+ var durationLabel = MusicUtils.formatDuration(entry.duration);
+ if (mainInstance?.showDurationMetadata !== false && durationLabel) {
+ parts.push(durationLabel);
+ }
+ var ratingLabel = formatRating(entry.rating);
+ if (mainInstance?.showRatingMetadata !== false && ratingLabel) {
+ parts.push(ratingLabel);
+ }
+ var tags = entry.tags || [];
+ if (mainInstance?.showTagMetadata !== false && tags.length > 0) {
+ parts.push(tags.map(function(t) { return "#" + t; }).join(" "));
+ }
+ return parts.join(" • ");
+ }
+
+ function buildSectionItem(name, description, icon) {
+ return {
+ "id": "section:" + (name || "").toLowerCase(),
+ "name": name,
+ "description": description || "",
+ "icon": icon || "music",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "section",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildLibraryResultItem(entry, options) {
+ var title = entry?.title || entry?.name || pluginApi?.tr("common.untitled");
+ var description = options?.description || buildDescription(entry, options?.prefix || pluginApi?.tr("library.saved"));
+ var isCurrent = ((entry?.id && entry?.id === mainInstance?.currentEntryId) || (!!entry?.url && entry?.url === mainInstance?.currentUrl));
+ var activePlayback = mainInstance?.isPlaying === true || mainInstance?.playbackStarting === true;
+ var icon = options?.icon || (isCurrent && activePlayback ? "disc" : "bookmark");
+ var kind = options?.kind || "library";
+
+ return {
+ "id": entry?.id || "",
+ "name": title,
+ "description": description,
+ "icon": icon,
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": kind,
+ "url": entry?.url || "",
+ "uploader": entry?.uploader || "",
+ "duration": entry?.duration || 0,
+ "isSaved": typeof entry?.isSaved === "boolean" ? entry.isSaved : undefined,
+ "savedAt": entry?.savedAt || "",
+ "providerName": entry?.provider || "",
+ "album": entry?.album || "",
+ "localPath": entry?.localPath || "",
+ "tags": entry?.tags || [],
+ "rating": entry?.rating || 0,
+ "playCount": entry?.playCount || 0,
+ "lastPlayedAt": entry?.lastPlayedAt || "",
+ "playlistId": options?.playlistId || entry?.playlistId || "",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playEntry(entry);
+ }
+ };
+ }
+
+ function parseTagTerms(tagQuery) {
+ var seen = ({});
+ var terms = [];
+ var rawTerms = (tagQuery || "").split(/\s+/);
+
+ for (var i = 0; i < rawTerms.length; i++) {
+ var normalized = normalizeTagValue(rawTerms[i]);
+ var key = normalized.toLowerCase();
+ if (key.length === 0 || seen[key]) {
+ continue;
+ }
+ seen[key] = true;
+ terms.push(normalized);
+ }
+
+ return terms;
+ }
+
+ function parseNumericComparison(value) {
+ var trimmed = (value || "").trim();
+ var match = trimmed.match(/^(<=|>=|=|<|>)?\s*(-?\d+(?:\.\d+)?)$/);
+ if (!match) {
+ return null;
+ }
+ return {
+ "operator": match[1] || "=",
+ "value": Number(match[2])
+ };
+ }
+
+ function matchesNumericComparison(actual, comparison) {
+ if (!comparison) {
+ return true;
+ }
+ var number = Number(actual || 0);
+ var target = Number(comparison.value || 0);
+ if (!isFinite(number) || !isFinite(target)) {
+ return false;
+ }
+ if (comparison.operator === ">") {
+ return number > target;
+ }
+ if (comparison.operator === ">=") {
+ return number >= target;
+ }
+ if (comparison.operator === "<") {
+ return number < target;
+ }
+ if (comparison.operator === "<=") {
+ return number <= target;
+ }
+ return number === target;
+ }
+
+ function parseRecentWindow(value) {
+ var trimmed = (value || "").trim().toLowerCase();
+ var match = trimmed.match(/^(\d+)([smhdwy])?$/);
+ if (!match) {
+ return 0;
+ }
+ var amount = Number(match[1] || 0);
+ var unit = match[2] || "d";
+ var secondsPerUnit = 86400;
+ if (unit === "s") secondsPerUnit = 1;
+ else if (unit === "m") secondsPerUnit = 60;
+ else if (unit === "h") secondsPerUnit = 3600;
+ else if (unit === "d") secondsPerUnit = 86400;
+ else if (unit === "w") secondsPerUnit = 604800;
+ else if (unit === "y") secondsPerUnit = 31536000;
+ return amount > 0 ? amount * secondsPerUnit : 0;
+ }
+
+ function isStructuredLibraryFilterToken(token) {
+ var lower = (token || "").toLowerCase();
+ return lower.startsWith("rating:")
+ || lower.startsWith("plays:")
+ || lower.startsWith("playcount:")
+ || lower.startsWith("recent:")
+ || lower.startsWith("album:")
+ || lower.startsWith("provider:")
+ || lower.startsWith("saved:")
+ || lower.startsWith("tag:")
+ || lower.startsWith("#");
+ }
+
+ function parseSavedFilterValue(value) {
+ var normalized = (value || "").trim().toLowerCase();
+ if (["true", "yes", "1", "saved"].indexOf(normalized) >= 0) {
+ return true;
+ }
+ if (["false", "no", "0", "unsaved", "playlist-only"].indexOf(normalized) >= 0) {
+ return false;
+ }
+ if (["any", "all", "*"].indexOf(normalized) >= 0) {
+ return "any";
+ }
+ return null;
+ }
+
+ function parseLibraryFilterQuery(query) {
+ var rawTerms = (query || "").trim().split(/\s+/).filter(function (term) {
+ return (term || "").trim().length > 0;
+ });
+ var parsed = {
+ "hasStructuredFilters": false,
+ "textQuery": "",
+ "textTerms": [],
+ "tagTerms": [],
+ "albumTerms": [],
+ "provider": "",
+ "saved": null,
+ "rating": null,
+ "plays": null,
+ "recentSeconds": 0,
+ "includeHidden": false
+ };
+
+ for (var i = 0; i < rawTerms.length; i++) {
+ var token = (rawTerms[i] || "");
+ var lower = token.toLowerCase();
+
+ if (token.startsWith("#")) {
+ var hashTag = normalizeTagValue(token.substring(1));
+ if (hashTag.length > 0) {
+ parsed.tagTerms.push(hashTag);
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("tag:")) {
+ var tagValue = normalizeTagValue(token.substring(4));
+ if (tagValue.length > 0) {
+ parsed.tagTerms.push(tagValue);
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("album:")) {
+ var albumValue = (token.substring(6) || "").trim();
+ while (i + 1 < rawTerms.length && !isStructuredLibraryFilterToken(rawTerms[i + 1])) {
+ albumValue += (albumValue.length > 0 ? " " : "") + (rawTerms[i + 1] || "").trim();
+ i += 1;
+ }
+ albumValue = (albumValue || "").trim();
+ if (albumValue.length > 0) {
+ parsed.albumTerms.push(albumValue);
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("provider:")) {
+ var providerValue = (token.substring(9) || "").trim().toLowerCase();
+ if (["youtube", "soundcloud", "local"].indexOf(providerValue) >= 0) {
+ parsed.provider = providerValue;
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("saved:")) {
+ var savedValue = parseSavedFilterValue(token.substring(6));
+ if (savedValue !== null) {
+ parsed.saved = savedValue;
+ parsed.includeHidden = savedValue === false || savedValue === "any";
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("rating:")) {
+ var ratingComparison = parseNumericComparison(token.substring(7));
+ if (ratingComparison) {
+ parsed.rating = ratingComparison;
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("plays:")) {
+ var playsComparison = parseNumericComparison(token.substring(6));
+ if (playsComparison) {
+ parsed.plays = playsComparison;
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("playcount:")) {
+ var playCountComparison = parseNumericComparison(token.substring(10));
+ if (playCountComparison) {
+ parsed.plays = playCountComparison;
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ if (lower.startsWith("recent:")) {
+ var recentSeconds = parseRecentWindow(token.substring(7));
+ if (recentSeconds > 0) {
+ parsed.recentSeconds = recentSeconds;
+ parsed.hasStructuredFilters = true;
+ continue;
+ }
+ }
+
+ parsed.textTerms.push(token);
+ }
+
+ parsed.textQuery = parsed.textTerms.join(" ").trim();
+ return parsed;
+ }
+
+ function libraryFilterQueryActive(query) {
+ return parseLibraryFilterQuery(query).hasStructuredFilters;
+ }
+
+ function entryActivityTimestamp(entry) {
+ var activity = (entry?.lastPlayedAt || entry?.savedAt || "").trim();
+ if (activity.length === 0) {
+ return 0;
+ }
+ var parsed = Date.parse(activity);
+ return isFinite(parsed) ? parsed : 0;
+ }
+
+ function entryMatchesLibraryFilters(entry, filters) {
+ if (!entry) {
+ return false;
+ }
+
+ if (filters.saved === true && entry.isSaved === false) {
+ return false;
+ }
+ if (filters.saved === false && entry.isSaved !== false) {
+ return false;
+ }
+
+ if (filters.provider.length > 0 && (entry.provider || "").trim().toLowerCase() !== filters.provider) {
+ return false;
+ }
+
+ if (!matchesNumericComparison(entry.rating, filters.rating)) {
+ return false;
+ }
+
+ if (!matchesNumericComparison(entry.playCount, filters.plays)) {
+ return false;
+ }
+
+ if (filters.recentSeconds > 0) {
+ var activityTime = entryActivityTimestamp(entry);
+ if (activityTime <= 0) {
+ return false;
+ }
+ var ageSeconds = (Date.now() - activityTime) / 1000;
+ if (ageSeconds > filters.recentSeconds) {
+ return false;
+ }
+ }
+
+ if (filters.albumTerms.length > 0) {
+ var albumText = (entry.album || "").toLowerCase();
+ for (var i = 0; i < filters.albumTerms.length; i++) {
+ if (albumText.indexOf((filters.albumTerms[i] || "").toLowerCase()) === -1) {
+ return false;
+ }
+ }
+ }
+
+ if (filters.tagTerms.length > 0 && !entryMatchesTagTerms(entry, filters.tagTerms)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function entryMatchesTagTerms(entry, tagTerms) {
+ if (!entry || tagTerms.length === 0) {
+ return false;
+ }
+
+ var normalizedTags = (entry.tags || []).map(function (tag) {
+ return normalizeTagValue(tag).toLowerCase();
+ });
+
+ for (var i = 0; i < tagTerms.length; i++) {
+ var term = normalizeTagValue(tagTerms[i]).toLowerCase();
+ var matchedTerm = false;
+ for (var j = 0; j < normalizedTags.length; j++) {
+ if (normalizedTags[j].indexOf(term) === 0 || normalizedTags[j].indexOf(term) >= 0) {
+ matchedTerm = true;
+ break;
+ }
+ }
+ if (!matchedTerm) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ function collectTagStats() {
+ var seen = ({});
+ var stats = [];
+ var library = mainInstance?.visibleLibraryEntries() || [];
+
+ for (var i = 0; i < library.length; i++) {
+ var entryTags = library[i].tags || [];
+ for (var j = 0; j < entryTags.length; j++) {
+ var normalizedTag = normalizeTagValue(entryTags[j]);
+ var key = normalizedTag.toLowerCase();
+ if (key.length === 0) {
+ continue;
+ }
+ if (!seen[key]) {
+ seen[key] = {
+ "tag": normalizedTag,
+ "count": 0
+ };
+ stats.push(seen[key]);
+ }
+ seen[key].count += 1;
+ }
+ }
+
+ stats.sort(function (a, b) {
+ if (b.count !== a.count) {
+ return b.count - a.count;
+ }
+ return a.tag.localeCompare(b.tag);
+ });
+ return stats;
+ }
+
+ function collectKnownTags() {
+ return collectTagStats().map(function (item) {
+ return item.tag;
+ });
+ }
+
+ function collectArtistStats() {
+ var seen = ({});
+ var stats = [];
+ var library = mainInstance?.visibleLibraryEntries() || [];
+
+ for (var i = 0; i < library.length; i++) {
+ var artist = (library[i].uploader || "").trim();
+ var key = artist.toLowerCase();
+ if (key.length === 0) {
+ continue;
+ }
+ if (!seen[key]) {
+ seen[key] = {
+ "name": artist,
+ "count": 0,
+ "playCount": 0,
+ "lastPlayedAt": ""
+ };
+ stats.push(seen[key]);
+ }
+ seen[key].count += 1;
+ seen[key].playCount += Number(library[i].playCount || 0);
+ var playedAt = (library[i].lastPlayedAt || "");
+ if (playedAt.length > 0 && playedAt > seen[key].lastPlayedAt) {
+ seen[key].lastPlayedAt = playedAt;
+ }
+ }
+
+ stats.sort(function (a, b) {
+ if (b.count !== a.count) {
+ return b.count - a.count;
+ }
+ if (b.playCount !== a.playCount) {
+ return b.playCount - a.playCount;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ return stats;
+ }
+
+ function recentPlayedEntries(limit) {
+ var library = (mainInstance?.visibleLibraryEntries() || []).filter(function (entry) {
+ return (entry.lastPlayedAt || "").trim().length > 0;
+ }).slice();
+
+ library.sort(function (a, b) {
+ return (b.lastPlayedAt || "").localeCompare((a.lastPlayedAt || ""));
+ });
+
+ return limit > 0 ? library.slice(0, limit) : library;
+ }
+
+ function topPlayedEntries(limit) {
+ var library = (mainInstance?.visibleLibraryEntries() || []).filter(function (entry) {
+ return Number(entry.playCount || 0) > 0;
+ }).slice();
+
+ library.sort(function (a, b) {
+ if (Number(b.playCount || 0) !== Number(a.playCount || 0)) {
+ return Number(b.playCount || 0) - Number(a.playCount || 0);
+ }
+ return (b.lastPlayedAt || "").localeCompare((a.lastPlayedAt || ""));
+ });
+
+ return limit > 0 ? library.slice(0, limit) : library;
+ }
+
+ function buildTagBrowseItem(tagStat) {
+ var tagName = (tagStat?.tag || "").trim();
+ var count = Number(tagStat?.count || 0);
+ return {
+ "id": "tag-browse:" + tagName.toLowerCase(),
+ "name": "#" + tagName,
+ "description": count === 1 ? (pluginApi?.tr("library.oneTrack")) : (pluginApi?.tr("library.trackCount", {"count": count})),
+ "icon": "tag",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "tag-browse",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " #" + tagName);
+ }
+ }
+ };
+ }
+
+ function buildArtistBrowseItem(artistStat) {
+ var artistName = (artistStat?.name || "").trim();
+ var count = Number(artistStat?.count || 0);
+ var parts = [count === 1 ? (pluginApi?.tr("library.oneTrack")) : (pluginApi?.tr("library.trackCount", {"count": count}))];
+ var playCountLabel = formatPlayCount(artistStat?.playCount || 0);
+ if (playCountLabel) {
+ parts.push(playCountLabel);
+ }
+ return {
+ "id": "artist-browse:" + artistName.toLowerCase(),
+ "name": artistName,
+ "description": parts.join(" • "),
+ "icon": "microphone-2",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "artist-browse",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " artist:" + artistName);
+ }
+ }
+ };
+ }
+
+ function buildSavedBrowseItem() {
+ var savedCount = (mainInstance?.visibleLibraryEntries() || []).length;
+ return {
+ "id": "saved-browse",
+ "name": pluginApi?.tr("library.savedTracks"),
+ "description": savedCount === 0 ? (pluginApi?.tr("library.libraryEmpty")) : (savedCount === 1 ? (pluginApi?.tr("library.oneTrack")) : (pluginApi?.tr("library.trackCount", {"count": savedCount}))),
+ "icon": "bookmark",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "saved-browse",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " saved:");
+ }
+ }
+ };
+ }
+
+ function buildMprisSearchItem() {
+ var trackTitle = (MediaService.trackTitle || "").trim();
+ if (trackTitle.length === 0) {
+ return null;
+ }
+
+ var providerLabel = mainInstance?.providerLabel() || pluginApi?.tr("providers.youtube");
+ var playerIdentity = (MediaService.playerIdentity || "").trim();
+ var description = playerIdentity.length > 0
+ ? pluginApi?.tr("actions.mprisTrackDescWithPlayer", {
+ "title": trackTitle,
+ "provider": providerLabel,
+ "player": playerIdentity
+ })
+ : pluginApi?.tr("actions.mprisTrackDesc", {
+ "title": trackTitle,
+ "provider": providerLabel
+ });
+
+ return {
+ "id": "mpris-search:" + trackTitle.toLowerCase(),
+ "name": pluginApi?.tr("actions.searchMprisTrack"),
+ "description": description,
+ "icon": "device-speaker",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "mpris-search",
+ "_score": 25,
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " " + trackTitle);
+ }
+ }
+ };
+ }
+
+ function buildImportFolderPromptItem() {
+ return {
+ "id": "import-folder-prompt",
+ "name": pluginApi?.tr("import.title"),
+ "description": pluginApi?.tr("import.desc"),
+ "icon": "folder-plus",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "import-folder-prompt",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " import: ");
+ }
+ }
+ };
+ }
+
+ function buildImportFolderItem(folderPath) {
+ var targetFolder = (folderPath || "").trim();
+ var segments = targetFolder.split("/").filter(function (part) { return part.length > 0; });
+ var playlistName = segments.length > 0 ? segments[segments.length - 1] : targetFolder;
+ return {
+ "id": "import-folder:" + targetFolder,
+ "name": pluginApi?.tr("import.title"),
+ "description": playlistName.length > 0
+ ? (pluginApi?.tr("import.createPlaylist", {"name": playlistName, "path": targetFolder}))
+ : targetFolder,
+ "icon": "folder-plus",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "import-folder",
+ "folderPath": targetFolder,
+ "_score": 9,
+ "onActivate": function () {
+ mainInstance?.importFolderAsPlaylist(targetFolder, "");
+ }
+ };
+ }
+
+ function buildSpeedItem(value) {
+ var target = Number(value);
+ var speedLabel = isFinite(target) ? formatSpeedLabel(target) : (value || "");
+ return {
+ "id": "speed:" + speedLabel,
+ "name": pluginApi?.tr("speed.setTo", {"speed": speedLabel}),
+ "description": pluginApi?.tr("speed.desc"),
+ "icon": "gauge",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "speed",
+ "onActivate": function () {
+ mainInstance?.setSpeed(target);
+ }
+ };
+ }
+
+ function buildSpeedItems(speedQuery) {
+ if (mainInstance?.isPlaying !== true) {
+ return [
+ buildSearchHintItem(pluginApi?.tr("speed.noPlayback"))
+ ];
+ }
+
+ var currentSpeed = mainInstance?.currentSpeed ?? 1;
+ if (!isFinite(currentSpeed) || currentSpeed <= 0) {
+ currentSpeed = 1;
+ }
+ var queryText = (speedQuery || "").trim();
+ var items = [
+ buildSectionItem(pluginApi?.tr("speed.title"), pluginApi?.tr("speed.current", {"speed": formatSpeedLabel(currentSpeed)}), "gauge")
+ ];
+
+ if (queryText.length === 0) {
+ var presets = [0.90, 0.95, 1.00, 1.05, 1.10, 1.25];
+ for (var i = 0; i < presets.length; i++) {
+ items.push(buildSpeedItem(presets[i]));
+ }
+ return items;
+ }
+
+ var target = Number(queryText);
+ if (!isFinite(target)) {
+ items.push(buildSearchHintItem(pluginApi?.tr("speed.useNumber")));
+ return items;
+ }
+
+ target = Math.max(0.25, Math.min(4, target));
+ items.push(buildSpeedItem(target));
+ return items;
+ }
+
+ function buildQueueActionItem(name, description, icon, score, activate) {
+ return {
+ "id": "queue-action:" + (name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-"),
+ "name": name,
+ "description": description,
+ "icon": icon,
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "queue-action",
+ "_score": score || 0,
+ "onActivate": activate
+ };
+ }
+
+ function buildQueueEntryItem(entry, index) {
+ var prefix = index === 0 ? pluginApi?.tr("queue.nextUp") : pluginApi?.tr("queue.queued");
+ var saved = entry?.isSaved === false
+ ? false
+ : (entry?.isSaved === true || mainInstance?.isSaved(entry) === true);
+ return {
+ "id": entry?.id || ("queue:" + index),
+ "name": entry?.title || pluginApi?.tr("common.untitled"),
+ "description": buildDescription(entry, prefix),
+ "icon": index === 0 ? "player-track-next" : "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "queue-entry",
+ "url": entry?.url || "",
+ "uploader": entry?.uploader || "",
+ "duration": entry?.duration || 0,
+ "isSaved": saved,
+ "providerName": entry?.provider || "",
+ "queuedAt": entry?.queuedAt || "",
+ "position": index,
+ "onActivate": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playQueueEntryNow(entry);
+ }
+ };
+ }
+
+ function buildQueueItems(queueQuery) {
+ var queryText = (queueQuery || "").trim().toLowerCase();
+ var queueEntries = mainInstance?.queueEntries || [];
+ var queuedCount = queueEntries.length;
+ var items = [
+ buildSectionItem(pluginApi?.tr("queue.title"),
+ mainInstance?.lastError
+ ? (pluginApi?.tr("errors.lastError", {"error": mainInstance.lastError}))
+ : (mainInstance?.lastNotice || (mainInstance?.queueActive ? (pluginApi?.tr("queue.active")) : (pluginApi?.tr("queue.idle")))),
+ mainInstance?.queueActive ? "list-check" : "playlist")
+ ];
+
+ if (queryText === "start") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.start"), pluginApi?.tr("queue.startNow"), "player-play", 20, function () {
+ mainInstance?.startQueue();
+ if (launcher) {
+ launcher.close();
+ }
+ }));
+ return items;
+ }
+
+ if (queryText === "stop") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.stop"), pluginApi?.tr("queue.stopPause"), "player-pause", 19, function () {
+ mainInstance?.stopQueue();
+ }));
+ return items;
+ }
+
+ if (queryText === "skip") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.skip"), pluginApi?.tr("queue.skipNow"), "player-skip-forward", 18, function () {
+ mainInstance?.skipQueue();
+ }));
+ return items;
+ }
+
+ if (queryText === "clear") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.clear"), pluginApi?.tr("queue.clearRemove"), "trash", 18, function () {
+ mainInstance?.clearQueue();
+ }));
+ return items;
+ }
+
+ if (queryText === "saved" || queryText === "library" || queryText === "autoplay") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.autoplaySaved"), pluginApi?.tr("queue.autoplaySavedDesc"), "bookmark", 17, function () {
+ mainInstance?.autoplaySavedTracks(false);
+ if (launcher) {
+ launcher.close();
+ }
+ }));
+ return items;
+ }
+
+ if (queryText === "saved shuffle" || queryText === "library shuffle" || queryText === "autoplay shuffle" || queryText === "shuffle saved") {
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.autoplaySavedShuffle"), pluginApi?.tr("queue.autoplaySavedShuffleDesc"), "arrows-shuffle", 16, function () {
+ mainInstance?.autoplaySavedTracks(true);
+ if (launcher) {
+ launcher.close();
+ }
+ }));
+ return items;
+ }
+
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.start"), pluginApi?.tr("queue.startArm"), "player-play", 8, function () {
+ mainInstance?.startQueue();
+ }));
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.stop"), pluginApi?.tr("queue.stopKeep"), "player-pause", 7, function () {
+ mainInstance?.stopQueue();
+ }));
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.skip"), pluginApi?.tr("queue.skipJump"), "player-skip-forward", 7, function () {
+ mainInstance?.skipQueue();
+ }));
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.autoplaySaved"), pluginApi?.tr("queue.autoplaySavedActiveDesc"), "bookmark", 6, function () {
+ mainInstance?.autoplaySavedTracks(false);
+ }));
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.autoplaySavedShuffle"), pluginApi?.tr("queue.autoplaySavedShuffleActiveDesc"), "arrows-shuffle", 5, function () {
+ mainInstance?.autoplaySavedTracks(true);
+ }));
+ items.push(buildQueueActionItem(pluginApi?.tr("queue.clear"), pluginApi?.tr("queue.clearEmpty"), "trash", 6, function () {
+ mainInstance?.clearQueue();
+ }));
+
+ if (queuedCount === 0) {
+ items.push({
+ "id": "queue-empty",
+ "name": pluginApi?.tr("queue.empty"),
+ "description": pluginApi?.tr("queue.emptyDesc"),
+ "icon": "playlist-off",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "queue-empty",
+ "onActivate": function () {}
+ });
+ return items;
+ }
+
+ for (var i = 0; i < queueEntries.length; i++) {
+ items.push(buildQueueEntryItem(queueEntries[i], i));
+ }
+
+ return items;
+ }
+
+ function buildHomeItems() {
+ var items = [];
+ var recentEntries = recentPlayedEntries(3);
+ var topEntries = topPlayedEntries(3);
+ var tagStats = collectTagStats().slice(0, 4);
+ var artistStats = collectArtistStats().slice(0, 4);
+ var playlists = (mainInstance?.playlistEntries || []).slice(0, 3);
+
+ items.push(buildSavedBrowseItem());
+ items.push(buildImportFolderPromptItem());
+
+ var mprisSearchItem = buildMprisSearchItem();
+ if (mprisSearchItem) {
+ items.push(mprisSearchItem);
+ }
+
+ if (mainInstance?.showHomeRecent !== false && recentEntries.length > 0) {
+ items.push(buildSectionItem(pluginApi?.tr("home.recentlyPlayed"), pluginApi?.tr("home.recentlyPlayedDesc"), "history"));
+ for (var i = 0; i < recentEntries.length; i++) {
+ var relativeTime = MusicUtils.formatRelativeTime(recentEntries[i].lastPlayedAt);
+ items.push(buildLibraryResultItem(recentEntries[i], {
+ "prefix": mainInstance?.showPlayStatsMetadata !== false && relativeTime
+ ? (pluginApi?.tr("home.recentWithTime", {"time": relativeTime}))
+ : (pluginApi?.tr("home.recent")),
+ "icon": recentEntries[i].id === mainInstance?.currentEntryId && mainInstance?.isPlaying ? "disc" : "history"
+ }));
+ }
+ }
+
+ if (mainInstance?.showHomeTop !== false && topEntries.length > 0) {
+ items.push(buildSectionItem(pluginApi?.tr("home.mostPlayed"), pluginApi?.tr("home.mostPlayedDesc"), "chart-bar"));
+ for (var j = 0; j < topEntries.length; j++) {
+ items.push(buildLibraryResultItem(topEntries[j], {
+ "prefix": mainInstance?.showPlayStatsMetadata !== false
+ ? (pluginApi?.tr("home.topWithPlays", {"plays": formatPlayCount(topEntries[j].playCount || 0)}))
+ : (pluginApi?.tr("home.top")),
+ "icon": topEntries[j].id === mainInstance?.currentEntryId && mainInstance?.isPlaying ? "disc" : "chart-bar"
+ }));
+ }
+ }
+
+ if (mainInstance?.showHomeTags !== false && tagStats.length > 0) {
+ items.push(buildSectionItem(pluginApi?.tr("home.tags"), pluginApi?.tr("home.tagsDesc"), "tag"));
+ for (var k = 0; k < tagStats.length; k++) {
+ items.push(buildTagBrowseItem(tagStats[k]));
+ }
+ }
+
+ if (mainInstance?.showHomeArtists !== false && artistStats.length > 0) {
+ items.push(buildSectionItem(pluginApi?.tr("home.artists"), pluginApi?.tr("home.artistsDesc"), "microphone-2"));
+ for (var m = 0; m < artistStats.length; m++) {
+ items.push(buildArtistBrowseItem(artistStats[m]));
+ }
+ }
+
+ if (mainInstance?.showHomePlaylists !== false && playlists.length > 0) {
+ items.push(buildSectionItem(pluginApi?.tr("home.playlists"), pluginApi?.tr("home.playlistsDesc"), "playlist"));
+ for (var n = 0; n < playlists.length; n++) {
+ items.push(buildPlaylistHeaderItem(playlists[n]));
+ }
+ }
+
+ if (items.length === 0) {
+ items = items.concat(buildLibraryItems("", 8));
+ }
+
+ return items;
+ }
+
+ function buildArtistItems(artistQuery) {
+ var queryText = (artistQuery || "").trim();
+ var queryLower = queryText.toLowerCase();
+ var artistStats = collectArtistStats();
+
+ if (artistStats.length === 0) {
+ return [
+ buildSearchHintItem(pluginApi?.tr("artists.noArtists"))
+ ];
+ }
+
+ if (queryLower.length === 0) {
+ return artistStats.map(function (artistStat) {
+ return buildArtistBrowseItem(artistStat);
+ });
+ }
+
+ var matchedArtists = artistStats.filter(function (artistStat) {
+ return (artistStat.name || "").toLowerCase().indexOf(queryLower) >= 0;
+ });
+
+ if (matchedArtists.length === 0) {
+ return [
+ buildSearchHintItem(pluginApi?.tr("artists.noMatch", {"query": queryText}))
+ ];
+ }
+
+ var targetArtist = matchedArtists.length === 1
+ ? matchedArtists[0]
+ : matchedArtists.find(function (artistStat) {
+ return (artistStat.name || "").toLowerCase() === queryLower;
+ });
+
+ if (!targetArtist) {
+ return matchedArtists.map(function (artistStat) {
+ return buildArtistBrowseItem(artistStat);
+ });
+ }
+
+ var artistEntries = (mainInstance?.visibleLibraryEntries() || []).filter(function (entry) {
+ return (entry.uploader || "").trim().toLowerCase() === (targetArtist.name || "").trim().toLowerCase();
+ }).slice();
+
+ artistEntries.sort(function (a, b) {
+ if ((b.lastPlayedAt || "") !== (a.lastPlayedAt || "")) {
+ return (b.lastPlayedAt || "").localeCompare((a.lastPlayedAt || ""));
+ }
+ return (b.savedAt || "").localeCompare((a.savedAt || ""));
+ });
+
+ var items = [
+ buildSectionItem(targetArtist.name,
+ mainInstance?.showPlayStatsMetadata !== false
+ ? (formatPlayCount(targetArtist.playCount || 0) || (targetArtist.count + " saved tracks"))
+ : (targetArtist.count + " saved tracks"),
+ "microphone-2")
+ ];
+ for (var i = 0; i < artistEntries.length; i++) {
+ var artistPrefix = mainInstance?.showPlayStatsMetadata !== false && (artistEntries[i].lastPlayedAt || "").length > 0
+ ? (pluginApi?.tr("home.artistWithTime", {"time": MusicUtils.formatRelativeTime(artistEntries[i].lastPlayedAt)}))
+ : (pluginApi?.tr("home.artist"));
+ items.push(buildLibraryResultItem(artistEntries[i], {
+ "prefix": artistPrefix,
+ "icon": artistEntries[i].id === mainInstance?.currentEntryId && mainInstance?.isPlaying ? "disc" : "music"
+ }));
+ }
+ return items;
+ }
+
+ function itemProviderKey(item) {
+ var explicitProvider = (item?.providerName || "").trim().toLowerCase();
+ if (explicitProvider === "youtube" || explicitProvider === "soundcloud" || explicitProvider === "local") {
+ return explicitProvider;
+ }
+
+ var rawProvider = item?.provider;
+ if (typeof rawProvider === "string") {
+ var normalizedProvider = (rawProvider || "").trim().toLowerCase();
+ if (normalizedProvider === "youtube" || normalizedProvider === "soundcloud" || normalizedProvider === "local") {
+ return normalizedProvider;
+ }
+ }
+
+ return (mainInstance?.currentProvider || "youtube");
+ }
+
+ function getPreviewData(item) {
+ if (!item) {
+ return null;
+ }
+
+ var previewItem = {};
+ for (var key in item) {
+ previewItem[key] = item[key];
+ }
+
+ previewItem.isSaved = item?.isSaved === false
+ ? false
+ : (item?.isSaved === true || mainInstance?.isSaved(item) === true);
+ previewItem.isPlaying = mainInstance?.isPlaying === true && ((item.id && mainInstance?.currentEntryId === item.id) || (!!item.url && mainInstance?.currentUrl === item.url));
+ previewItem.isStarting = mainInstance?.playbackStarting === true && ((item.id && mainInstance?.currentEntryId === item.id) || (!!item.url && mainInstance?.currentUrl === item.url));
+ previewItem.isPaused = mainInstance?.isPaused === true;
+ previewItem.currentUrl = mainInstance?.currentUrl || "";
+ previewItem.lastError = mainInstance?.lastError || "";
+ previewItem.helperPath = helperPath;
+ previewItem.previewDelayMs = 500;
+ previewItem.previewMetadataMode = mainInstance?.previewMetadataMode || pluginApi?.pluginSettings?.previewMetadataMode || pluginApi?.manifest?.metadata?.defaultSettings?.previewMetadataMode || "always";
+ previewItem.sourceLabel = item.kind === "library"
+ ? (pluginApi?.tr("library.label"))
+ : (item.kind === "queue-entry"
+ ? (pluginApi?.tr("queue.title"))
+ : (item.kind === "search"
+ ? (mainInstance?.providerLabel(itemProviderKey(item)) || pluginApi?.tr("providers.youtube"))
+ : (item.kind === "custom-url" || item.kind === "save-url" ? (pluginApi?.tr("common.customUrl")) : (pluginApi?.tr("common.music")))));
+ return previewItem;
+ }
+
+ function getResults(searchText) {
+ if (!searchText.startsWith(commandName)) {
+ return [];
+ }
+
+ var query = searchText.substring(commandName.length).trim();
+ var commandQuery = normalizeToken(query);
+ var rawQueryLower = (query || "").toLowerCase();
+ var results = [];
+
+ if (root.playlistPickerEntryId.length > 0 && !rawQueryLower.startsWith("playlist:")) {
+ root.clearPlaylistSelection();
+ }
+ if (root.playlistRenameId.length > 0 && !rawQueryLower.startsWith("playlist:")) {
+ root.clearPlaylistRename();
+ }
+ if (root.tagEditorEntryId.length > 0 && !rawQueryLower.startsWith("tag:")) {
+ root.clearTagEditor();
+ }
+ if (root.metadataEditorEntryId.length > 0 && !rawQueryLower.startsWith("edit:")) {
+ root.clearMetadataEditor();
+ }
+
+ results.push(buildStatusItem());
+
+ if ((mainInstance?.isPlaying || mainInstance?.playbackStarting) && (query.length === 0 || (commandQuery.length > 0 && "stop".indexOf(commandQuery) === 0))) {
+ results.push(buildStopItem());
+ }
+
+ if (commandQuery === "stop") {
+ if (!mainInstance?.isPlaying && !mainInstance?.playbackStarting) {
+ results.push(buildIdleStopItem());
+ }
+ return results;
+ }
+
+ if (query.length === 0) {
+ results = results.concat(buildHomeItems());
+ results.push(buildSearchHintItem());
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("saved:") && !libraryFilterQueryActive(query)) {
+ var savedQuery = query.substring(6).trim();
+ var libraryCount = (mainInstance?.visibleLibraryEntries() || []).length;
+ results = results.concat(buildLibraryItems(savedQuery, savedQuery.length > 0 ? Math.max(libraryCount, 1) : 0));
+ if (results.length <= 1) {
+ results.push(buildSearchHintItem(savedQuery.length > 0
+ ? (pluginApi?.tr("library.noMatches", {"query": savedQuery}))
+ : (pluginApi?.tr("library.savedEmpty"))));
+ }
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("speed:")) {
+ var speedQuery = query.substring(6).trim();
+ results = results.concat(buildSpeedItems(speedQuery));
+ return results;
+ }
+
+ if (rawQueryLower === "queue" || rawQueryLower.startsWith("queue ")) {
+ var queueQuery = rawQueryLower === "queue" ? "" : query.substring(6).trim();
+ results = results.concat(buildQueueItems(queueQuery));
+ return results;
+ }
+
+ if (query.startsWith("#")) {
+ var tagQuery = query.substring(1).trim();
+ if (tagQuery.length > 0) {
+ results = results.concat(buildTagFilteredItems(tagQuery));
+ }
+ if (results.length <= 1) {
+ results.push(buildSearchHintItem(pluginApi?.tr("library.noTagged", {"tag": tagQuery})));
+ }
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("tag:")) {
+ var manageTagQuery = query.substring(4).trim();
+ results = results.concat(buildTagEditorItems(manageTagQuery));
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("edit:")) {
+ var editQuery = query.substring(5).trim();
+ results = results.concat(buildMetadataEditorItems(editQuery));
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("import:")) {
+ var importFolderQuery = query.substring(7).trim();
+ if (importFolderQuery.length === 0) {
+ results.push(buildImportFolderPromptItem());
+ results.push(buildSearchHintItem(pluginApi?.tr("import.hint")));
+ return results;
+ }
+ results.push(buildImportFolderItem(importFolderQuery));
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("playlist:")) {
+ var playlistQuery = query.substring(9).trim();
+ results = results.concat(root.playlistRenameId
+ ? buildPlaylistRenameItems(playlistQuery)
+ : (root.playlistPickerEntryId ? buildPlaylistPickerItems(playlistQuery) : buildPlaylistItems(playlistQuery)));
+ return results;
+ }
+
+ if (rawQueryLower.startsWith("artist:")) {
+ var artistQuery = query.substring(7).trim();
+ results = results.concat(buildArtistItems(artistQuery));
+ return results;
+ }
+
+ if (looksLikeUrl(query)) {
+ results.push(buildPlayUrlItem(query));
+ results.push(buildSaveUrlItem(query));
+ results.push(buildDownloadUrlItem(query));
+ results = results.concat(buildLibraryItems(query, 4));
+ return results;
+ }
+
+ if (libraryFilterQueryActive(query)) {
+ var filterLibraryCount = (mainInstance?.libraryEntries || []).length;
+ results = results.concat(buildLibraryItems(query, filterLibraryCount > 0 ? filterLibraryCount : 0));
+ if (results.length <= 1) {
+ results.push(buildSearchHintItem(pluginApi?.tr("library.noFilterMatches")));
+ }
+ return results;
+ }
+
+ var searchContext = parseSearchProviderQuery(query);
+ var searchQuery = (searchContext.query || "").trim();
+ var searchProvider = (searchContext.provider || mainInstance?.currentProvider || "youtube");
+ var searchProviderLabel = mainInstance?.providerLabel(searchProvider) || pluginApi?.tr("providers.youtube");
+
+ if (searchContext.explicit && searchQuery.length === 0) {
+ results.push(buildSearchHintItem(pluginApi?.tr("search.typeMore", {"provider": searchProviderLabel})));
+ return results;
+ }
+
+ results = results.concat(buildLibraryItems(searchQuery, 5));
+
+ if (searchQuery.length < 2) {
+ results.push(buildSearchHintItem(pluginApi?.tr("search.typeMore", {"provider": searchProviderLabel})));
+ return results;
+ }
+
+ ensureSearch(query);
+
+ if (searchBusy && lastCompletedQuery !== query) {
+ results.push(buildLoadingItem(searchQuery, searchProvider));
+ return results;
+ }
+
+ if (lastCompletedQuery === query && searchError) {
+ results.push(buildSearchErrorItem(searchError));
+ return results;
+ }
+
+ if (lastCompletedQuery === query) {
+ for (var i = 0; i < searchResults.length; i++) {
+ results.push(buildSearchResultItem(searchResults[i]));
+ }
+ }
+
+ if (results.length === 1 || (results.length === 2 && results[1].kind === "loading")) {
+ results.push(buildSearchHintItem(pluginApi?.tr("search.noResults", {"query": searchQuery})));
+ }
+
+ return results;
+ }
+
+ function ensureSearch(query) {
+ if (query === lastCompletedQuery && !searchBusy) {
+ return;
+ }
+
+ if (searchBusy) {
+ pendingSearchQuery = query;
+ return;
+ }
+
+ startSearch(query);
+ }
+
+ function startSearch(query) {
+ if (!helperPath) {
+ return;
+ }
+
+ var searchContext = parseSearchProviderQuery(query);
+ var provider = searchContext.provider;
+ var resolvedQuery = (searchContext.query || "").trim();
+ activeSearchQuery = query;
+ pendingSearchQuery = "";
+ pendingSearchRestart = false;
+ searchBusy = true;
+ runningSearchQuery = query;
+ runningSearchProvider = provider;
+ runningSearchEpoch = searchEpoch;
+ searchProcess.exec({
+ "command": ["bash", helperPath, "search", resolvedQuery, provider]
+ });
+ }
+
+ function clearPlaylistSelection() {
+ playlistPickerEntryId = "";
+ playlistPickerEntryTitle = "";
+ }
+
+ function clearPlaylistRename() {
+ playlistRenameId = "";
+ playlistRenameTitle = "";
+ }
+
+ function clearTagEditor() {
+ tagEditorEntryId = "";
+ tagEditorEntryTitle = "";
+ }
+
+ function clearMetadataEditor() {
+ metadataEditorEntryId = "";
+ metadataEditorEntryTitle = "";
+ metadataEditorField = "";
+ }
+
+ function startPlaylistSelection(entry) {
+ var savedEntry = mainInstance?.findSavedEntry(entry);
+ var targetEntry = savedEntry || entry;
+ var entryId = (targetEntry?.id || "").trim();
+ if (entryId.length === 0) {
+ return;
+ }
+
+ playlistPickerEntryId = entryId;
+ playlistPickerEntryTitle = targetEntry?.title || targetEntry?.name || pluginApi?.tr("common.untitled");
+ root.clearPlaylistRename();
+ root.clearMetadataEditor();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:");
+ }
+ }
+
+ function startPlaylistRename(playlist) {
+ var playlistId = (playlist?.id || "").trim();
+ if (playlistId.length === 0) {
+ return;
+ }
+
+ playlistRenameId = playlistId;
+ playlistRenameTitle = playlist?.name || pluginApi?.tr("playlists.untitled");
+ root.clearPlaylistSelection();
+ root.clearMetadataEditor();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:" + playlistRenameTitle);
+ }
+ }
+
+ function normalizeTagValue(value) {
+ return (value || "").replace(/^#+/, "").replace(/\s+/g, " ").trim();
+ }
+
+ function currentTagEditorEntry() {
+ if (tagEditorEntryId.length === 0) {
+ return null;
+ }
+
+ var library = mainInstance?.visibleLibraryEntries() || [];
+ for (var i = 0; i < library.length; i++) {
+ if ((library[i].id || "") === tagEditorEntryId) {
+ return library[i];
+ }
+ }
+
+ return null;
+ }
+
+ function entryHasTag(entry, tag) {
+ var normalizedTag = normalizeTagValue(tag).toLowerCase();
+ if (!entry || normalizedTag.length === 0) {
+ return false;
+ }
+
+ var tags = entry.tags || [];
+ for (var i = 0; i < tags.length; i++) {
+ if (normalizeTagValue(tags[i]).toLowerCase() === normalizedTag) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function startTagEditing(entry) {
+ var savedEntry = mainInstance?.findSavedEntry(entry);
+ var targetEntry = savedEntry || entry;
+ var entryId = (targetEntry?.id || "").trim();
+ if (entryId.length === 0) {
+ return;
+ }
+
+ tagEditorEntryId = entryId;
+ tagEditorEntryTitle = targetEntry?.title || targetEntry?.name || pluginApi?.tr("common.untitled");
+ root.clearMetadataEditor();
+ if (launcher) {
+ launcher.setSearchText(commandName + " tag:");
+ }
+ }
+
+ function metadataFieldLabel(field) {
+ var normalized = (field || "").trim().toLowerCase();
+ if (normalized === "title") return pluginApi?.tr("metadata.titleField");
+ if (normalized === "artist" || normalized === "uploader") return pluginApi?.tr("metadata.artistField");
+ if (normalized === "album") return pluginApi?.tr("metadata.albumField");
+ return pluginApi?.tr("metadata.label");
+ }
+
+ function normalizeMetadataField(field) {
+ var normalized = (field || "").trim().toLowerCase();
+ if (normalized === "artist") return "uploader";
+ if (normalized === "uploader") return "uploader";
+ if (normalized === "album") return "album";
+ if (normalized === "title") return "title";
+ return "";
+ }
+
+ function currentMetadataEditorEntry() {
+ if (metadataEditorEntryId.length === 0) {
+ return null;
+ }
+
+ var library = mainInstance?.libraryEntries || [];
+ for (var i = 0; i < library.length; i++) {
+ if ((library[i].id || "") === metadataEditorEntryId) {
+ return library[i];
+ }
+ }
+ return null;
+ }
+
+ function startMetadataEditing(entry, preferredField) {
+ var targetEntry = mainInstance?.findLibraryEntry(entry);
+ var entryId = (targetEntry?.id || "").trim();
+ if (entryId.length === 0) {
+ return;
+ }
+
+ metadataEditorEntryId = entryId;
+ metadataEditorEntryTitle = targetEntry?.title || targetEntry?.name || pluginApi?.tr("common.untitled");
+ metadataEditorField = normalizeMetadataField(preferredField);
+ root.clearPlaylistSelection();
+ root.clearPlaylistRename();
+ root.clearTagEditor();
+ if (launcher) {
+ launcher.setSearchText(commandName + " edit:" + (metadataEditorField.length > 0 ? (metadataEditorField + " ") : ""));
+ }
+ }
+
+ function buildStatusItem() {
+ var playing = mainInstance?.isPlaying === true;
+ var starting = mainInstance?.playbackStarting === true;
+ var title = playing || starting ? (mainInstance?.currentTitle || (starting ? (pluginApi?.tr("status.starting")) : (pluginApi?.tr("status.nowPlaying")))) : (pluginApi?.tr("status.ready"));
+ var savedCurrentEntry = mainInstance?.findSavedEntry({
+ "id": mainInstance?.currentEntryId || "",
+ "url": mainInstance?.currentUrl || ""
+ }) || null;
+ var currentEntry = {
+ "id": mainInstance?.currentEntryId || "",
+ "title": title,
+ "url": mainInstance?.currentUrl || "",
+ "uploader": mainInstance?.currentUploader || "",
+ "duration": mainInstance?.currentDuration || 0,
+ "tags": savedCurrentEntry?.tags || []
+ };
+ var providerName = mainInstance?.providerLabel() || pluginApi?.tr("providers.youtube");
+ var description = playing ? buildDescription({
+ "uploader": mainInstance?.currentUploader || "",
+ "duration": mainInstance?.currentDuration || 0
+ }, pluginApi?.tr("status.backgroundPlayback")) : (starting
+ ? (mainInstance?.playbackStartingMessage || (pluginApi?.tr("status.startingProviderPlayback", {"provider": providerName})))
+ : (mainInstance?.lastError ? (pluginApi?.tr("errors.lastError", {"error": mainInstance.lastError})) : (mainInstance?.lastNotice || (pluginApi?.tr("status.searchPrompt", {"provider": providerName})))));
+
+ return {
+ "id": currentEntry.id,
+ "name": title,
+ "title": currentEntry.title,
+ "description": description,
+ "icon": playing ? (mainInstance?.isPaused ? "player-pause" : "disc") : (starting ? "disc" : "music"),
+ "isTablerIcon": true,
+ "isImage": false,
+ "badgeIcon": (playing || starting) && mainInstance?.isSaved(currentEntry) ? "bookmark-filled" : "",
+ "provider": root,
+ "kind": playing || starting ? "status" : "status-idle",
+ "url": currentEntry.url,
+ "uploader": currentEntry.uploader,
+ "duration": currentEntry.duration,
+ "onActivate": function () {}
+ };
+ }
+
+ function buildStopItem() {
+ return {
+ "name": pluginApi?.tr("actions.stopMusic"),
+ "description": mainInstance?.currentTitle ? (pluginApi?.tr("actions.stopTitle", {"title": mainInstance.currentTitle})) : (pluginApi?.tr("actions.stopDesc")),
+ "icon": "player-stop",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "_score": 5,
+ "onActivate": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.stopPlayback();
+ }
+ };
+ }
+
+ function buildIdleStopItem() {
+ return {
+ "name": pluginApi?.tr("actions.alreadyStopped"),
+ "description": pluginApi?.tr("actions.nothingPlaying"),
+ "icon": "player-stop",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ };
+ }
+
+ function buildLoadingItem(query, provider) {
+ return {
+ "name": pluginApi?.tr("search.searching", {"provider": mainInstance?.providerLabel(provider) || pluginApi?.tr("providers.youtube")}),
+ "description": query,
+ "icon": "search",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "loading",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildSearchErrorItem(message) {
+ return {
+ "name": pluginApi?.tr("search.failed"),
+ "description": message || pluginApi?.tr("search.failedDefault"),
+ "icon": "alert-circle",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ };
+ }
+
+ function buildSearchHintItem(message) {
+ return {
+ "name": pluginApi?.tr("search.title"),
+ "description": message || pluginApi?.tr("search.hint"),
+ "icon": "search",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ };
+ }
+
+ function buildPlayUrlItem(urlText) {
+ return {
+ "name": pluginApi?.tr("actions.playUrl"),
+ "description": (urlText || "").trim(),
+ "icon": "player-play",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "custom-url",
+ "url": (urlText || "").trim(),
+ "_score": 10,
+ "onActivate": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playUrl(urlText, pluginApi?.tr("common.customUrl"));
+ }
+ };
+ }
+
+ function buildSaveUrlItem(urlText) {
+ return {
+ "name": pluginApi?.tr("actions.saveUrl"),
+ "description": (urlText || "").trim(),
+ "icon": "bookmark-plus",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "save-url",
+ "url": (urlText || "").trim(),
+ "_score": 9,
+ "onActivate": function () {
+ mainInstance?.saveUrl(urlText);
+ }
+ };
+ }
+
+ function buildDownloadUrlItem(urlText) {
+ return {
+ "name": pluginApi?.tr("actions.saveUrlMp3"),
+ "description": pluginApi?.tr("actions.downloadDesc"),
+ "icon": "download",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "download-url",
+ "url": (urlText || "").trim(),
+ "_score": 8,
+ "onActivate": function () {
+ mainInstance?.downloadUrl(urlText, pluginApi?.tr("common.downloadedTrack"));
+ }
+ };
+ }
+
+ function buildLibraryItems(query, limit) {
+ var filters = parseLibraryFilterQuery(query);
+ var library = filters.includeHidden ? (mainInstance?.libraryEntries || []) : (mainInstance?.visibleLibraryEntries() || []);
+ if (library.length === 0) {
+ if (query.length === 0) {
+ return [
+ {
+ "name": pluginApi?.tr("library.empty"),
+ "description": pluginApi?.tr("library.emptyDesc"),
+ "icon": "bookmark-off",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ }
+ ];
+ }
+ return [];
+ }
+
+ var entries = library.filter(function (entry) {
+ return entryMatchesLibraryFilters(entry, filters);
+ }).slice();
+ var sortBy = mainInstance?.currentSortBy || "date";
+ entries.sort(function (a, b) {
+ if (sortBy === "title") {
+ return (a.title || "").localeCompare((b.title || ""));
+ }
+ if (sortBy === "duration") {
+ return (Number(b.duration) || 0) - (Number(a.duration) || 0);
+ }
+ if (sortBy === "rating") {
+ return (Number(b.rating) || 0) - (Number(a.rating) || 0);
+ }
+ return (b.savedAt || "").localeCompare((a.savedAt || ""));
+ });
+
+ var matchedEntries = entries;
+ if (filters.textQuery.length > 0) {
+ matchedEntries = FuzzySort.go(filters.textQuery, entries.map(function (entry) {
+ return {
+ "entry": entry,
+ "title": entry.title || "",
+ "uploader": entry.uploader || "",
+ "album": entry.album || "",
+ "localPath": entry.localPath || "",
+ "url": entry.url || ""
+ };
+ }), {
+ "keys": ["title", "uploader", "album", "localPath", "url"],
+ "limit": limit > 0 ? limit : entries.length
+ }).map(function (match) {
+ return match.obj.entry;
+ });
+ } else if (limit > 0) {
+ matchedEntries = entries.slice(0, limit);
+ }
+
+ return matchedEntries.map(function (entry) {
+ return buildLibraryResultItem(entry, {
+ "prefix": entry?.isSaved === false ? (pluginApi?.tr("library.playlistOnly")) : (pluginApi?.tr("library.saved"))
+ });
+ });
+ }
+
+ function buildTagFilteredItems(tagQuery) {
+ var library = mainInstance?.libraryEntries || [];
+ var tagTerms = parseTagTerms(tagQuery);
+ var tagLabel = tagTerms.map(function (tag) {
+ return "#" + tag;
+ }).join(" ");
+
+ var matched = library.filter(function (entry) {
+ return entryMatchesTagTerms(entry, tagTerms);
+ }).slice();
+
+ matched.sort(function (a, b) {
+ if (Number(b.playCount || 0) !== Number(a.playCount || 0)) {
+ return Number(b.playCount || 0) - Number(a.playCount || 0);
+ }
+ if ((b.lastPlayedAt || "") !== (a.lastPlayedAt || "")) {
+ return (b.lastPlayedAt || "").localeCompare((a.lastPlayedAt || ""));
+ }
+ return (b.savedAt || "").localeCompare((a.savedAt || ""));
+ });
+
+ return matched.map(function (entry) {
+ return buildLibraryResultItem(entry, {
+ "prefix": tagLabel || "#tag",
+ "icon": entry.id === mainInstance?.currentEntryId && mainInstance?.isPlaying ? "disc" : "tag"
+ });
+ });
+ }
+
+ function buildTagEditorHeaderItem(entry) {
+ var tags = entry?.tags || [];
+ var countLabel = tags.length === 0 ? (pluginApi?.tr("tags.noTags")) : (tags.length === 1 ? (pluginApi?.tr("tags.oneTag")) : (pluginApi?.tr("tags.tagCount", {"count": tags.length})));
+
+ return {
+ "id": entry?.id || "tag-editor",
+ "name": pluginApi?.tr("tags.manage"),
+ "description": pluginApi?.tr("tags.headerDescription", {
+ "title": entry?.title || tagEditorEntryTitle || pluginApi?.tr("common.untitled"),
+ "count": countLabel
+ }),
+ "icon": "tag",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "tag-header",
+ "url": entry?.url || "",
+ "uploader": entry?.uploader || "",
+ "duration": entry?.duration || 0,
+ "tags": tags,
+ "rating": entry?.rating || 0,
+ "onActivate": function () {}
+ };
+ }
+
+ function buildTagEditorHintItem(message) {
+ return {
+ "name": pluginApi?.tr("tags.editor"),
+ "description": message,
+ "icon": "tag",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "tag-hint",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildTagActionItem(entry, tag, assigned) {
+ var normalizedTag = normalizeTagValue(tag);
+ return {
+ "id": (entry?.id || "tag") + ":" + normalizedTag.toLowerCase() + ":" + (assigned ? "remove" : "add"),
+ "name": assigned ? (pluginApi?.tr("tags.remove", {"tag": normalizedTag})) : (pluginApi?.tr("tags.add", {"tag": normalizedTag})),
+ "description": assigned ? (pluginApi?.tr("tags.removeFrom", {"title": entry?.title || tagEditorEntryTitle || pluginApi?.tr("common.untitled")})) : (pluginApi?.tr("tags.applyTo", {"title": entry?.title || tagEditorEntryTitle || pluginApi?.tr("common.untitled")})),
+ "icon": assigned ? "x" : "tag",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": assigned ? "tag-remove" : "tag-add",
+ "url": entry?.url || "",
+ "uploader": entry?.uploader || "",
+ "duration": entry?.duration || 0,
+ "tags": entry?.tags || [],
+ "rating": entry?.rating || 0,
+ "onActivate": function () {
+ if (assigned) {
+ mainInstance?.untagEntry(entry?.id || "", normalizedTag);
+ } else {
+ mainInstance?.tagEntry(entry?.id || "", normalizedTag);
+ }
+ if (launcher) {
+ launcher.setSearchText(commandName + " tag:");
+ }
+ }
+ };
+ }
+
+ function buildTagEditorItems(tagQuery) {
+ var entry = currentTagEditorEntry();
+ if (!entry) {
+ return [
+ buildTagEditorHintItem(pluginApi?.tr("tags.chooseTrack"))
+ ];
+ }
+
+ var items = [buildTagEditorHeaderItem(entry)];
+ var normalizedQuery = normalizeTagValue(tagQuery);
+ var normalizedQueryLower = normalizedQuery.toLowerCase();
+ var seenKeys = ({});
+ var currentTags = (entry.tags || []).slice().sort(function (a, b) {
+ return normalizeTagValue(a).localeCompare(normalizeTagValue(b));
+ });
+
+ function pushTagItem(tag, assigned) {
+ var normalizedTag = normalizeTagValue(tag);
+ var key = normalizedTag.toLowerCase() + ":" + (assigned ? "remove" : "add");
+ if (normalizedTag.length === 0 || seenKeys[key]) {
+ return;
+ }
+ seenKeys[key] = true;
+ items.push(buildTagActionItem(entry, normalizedTag, assigned));
+ }
+
+ if (normalizedQueryLower.length > 0) {
+ pushTagItem(normalizedQuery, entryHasTag(entry, normalizedQuery));
+ }
+
+ for (var i = 0; i < currentTags.length; i++) {
+ var existingTag = normalizeTagValue(currentTags[i]);
+ if (normalizedQueryLower.length > 0 && existingTag.toLowerCase().indexOf(normalizedQueryLower) === -1) {
+ continue;
+ }
+ pushTagItem(existingTag, true);
+ }
+
+ var suggestionCount = 0;
+ var knownTags = collectKnownTags();
+ for (var j = 0; j < knownTags.length; j++) {
+ var knownTag = normalizeTagValue(knownTags[j]);
+ if (entryHasTag(entry, knownTag)) {
+ continue;
+ }
+ if (normalizedQueryLower.length > 0 && knownTag.toLowerCase().indexOf(normalizedQueryLower) === -1) {
+ continue;
+ }
+ pushTagItem(knownTag, false);
+ suggestionCount += 1;
+ if (normalizedQueryLower.length === 0 && suggestionCount >= 6) {
+ break;
+ }
+ }
+
+ if (items.length === 1) {
+ items.push(buildTagEditorHintItem(normalizedQuery.length > 0
+ ? (pluginApi?.tr("tags.noMatch", {"query": normalizedQuery}))
+ : (pluginApi?.tr("tags.hint"))));
+ }
+
+ return items;
+ }
+
+ function buildMetadataEditorHeaderItem(entry, field) {
+ var label = metadataFieldLabel(field);
+ var currentValue = "";
+ if (field === "title") {
+ currentValue = (entry?.title || "");
+ } else if (field === "uploader") {
+ currentValue = (entry?.uploader || "");
+ } else if (field === "album") {
+ currentValue = (entry?.album || "");
+ }
+
+ return {
+ "id": entry?.id || "metadata-editor",
+ "name": pluginApi?.tr("metadata.fieldEditor", {"field": label}),
+ "description": pluginApi?.tr("metadata.headerDescription", {
+ "title": entry?.title || metadataEditorEntryTitle || (pluginApi?.tr("common.untitled")),
+ "value": currentValue.length > 0 ? currentValue : (pluginApi?.tr("metadata.currentlyEmpty"))
+ }),
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "metadata-header",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildMetadataEditorHintItem(message) {
+ return {
+ "name": pluginApi?.tr("metadata.edit"),
+ "description": message,
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "metadata-hint",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildMetadataFieldItem(entry, field) {
+ var normalizedField = normalizeMetadataField(field);
+ var value = "";
+ if (normalizedField === "title") {
+ value = (entry?.title || "");
+ } else if (normalizedField === "uploader") {
+ value = (entry?.uploader || "");
+ } else if (normalizedField === "album") {
+ value = (entry?.album || "");
+ }
+
+ return {
+ "id": (entry?.id || "") + ":metadata:" + normalizedField,
+ "name": pluginApi?.tr("metadata.editField", {"field": metadataFieldLabel(normalizedField)}),
+ "description": value.length > 0 ? value : (pluginApi?.tr("metadata.empty")),
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "metadata-field",
+ "onActivate": function () {
+ metadataEditorField = normalizedField;
+ if (launcher) {
+ launcher.setSearchText(commandName + " edit:" + normalizedField + " ");
+ }
+ }
+ };
+ }
+
+ function buildMetadataApplyItem(entry, field, value) {
+ var normalizedField = normalizeMetadataField(field);
+ var targetValue = (value || "");
+ var label = metadataFieldLabel(normalizedField);
+ var description = targetValue.trim().length > 0 ? targetValue : (pluginApi?.tr("metadata.clearValue"));
+
+ return {
+ "id": (entry?.id || "") + ":metadata:" + normalizedField + ":" + description.toLowerCase(),
+ "name": targetValue.trim().length > 0 ? (pluginApi?.tr("metadata.setField", {"field": label})) : (pluginApi?.tr("metadata.clearField", {"field": label})),
+ "description": description,
+ "icon": "check",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "metadata-apply",
+ "onActivate": function () {
+ mainInstance?.editMetadata((entry?.id || ""), normalizedField, targetValue);
+ root.clearMetadataEditor();
+ if (launcher) {
+ launcher.setSearchText(commandName + " ");
+ }
+ }
+ };
+ }
+
+ function buildMetadataEditorItems(editQuery) {
+ var entry = currentMetadataEditorEntry();
+ if (!entry) {
+ return [
+ buildMetadataEditorHintItem(pluginApi?.tr("metadata.chooseTrack"))
+ ];
+ }
+
+ var queryText = (editQuery || "").trim();
+ var field = metadataEditorField;
+ var value = "";
+
+ if (queryText.length > 0) {
+ var spaceIndex = queryText.indexOf(" ");
+ var firstToken = spaceIndex >= 0 ? queryText.substring(0, spaceIndex) : queryText;
+ var normalizedToken = normalizeMetadataField(firstToken);
+ if (normalizedToken.length > 0) {
+ field = normalizedToken;
+ metadataEditorField = normalizedToken;
+ value = spaceIndex >= 0 ? queryText.substring(spaceIndex + 1) : "";
+ } else if (field.length > 0) {
+ value = queryText;
+ }
+ }
+
+ if (field.length === 0) {
+ return [
+ buildMetadataEditorHintItem(pluginApi?.tr("metadata.chooseField", {"title": entry?.title || metadataEditorEntryTitle || pluginApi?.tr("common.untitled")})),
+ buildMetadataFieldItem(entry, "title"),
+ buildMetadataFieldItem(entry, "artist"),
+ buildMetadataFieldItem(entry, "album")
+ ];
+ }
+
+ var items = [buildMetadataEditorHeaderItem(entry, field)];
+ var trimmedValue = (value || "").trim();
+ if (trimmedValue.length === 0) {
+ items.push(buildMetadataEditorHintItem(
+ field === "album"
+ ? (pluginApi?.tr("metadata.albumHint"))
+ : (pluginApi?.tr("metadata.typeNew", {"field": metadataFieldLabel(field).toLowerCase()}))
+ ));
+ if (field === "album" && (entry?.album || "").trim().length > 0) {
+ items.push(buildMetadataApplyItem(entry, field, ""));
+ }
+ } else {
+ items.push(buildMetadataApplyItem(entry, field, value));
+ }
+
+ if (field !== "title") {
+ items.push(buildMetadataFieldItem(entry, "title"));
+ }
+ if (field !== "uploader") {
+ items.push(buildMetadataFieldItem(entry, "artist"));
+ }
+ if (field !== "album") {
+ items.push(buildMetadataFieldItem(entry, "album"));
+ }
+
+ return items;
+ }
+
+ function findPlaylistMatches(playlistQuery) {
+ var playlists = mainInstance?.playlistEntries || [];
+ var queryLower = (playlistQuery || "").toLowerCase();
+
+ if (queryLower.length === 0) {
+ return playlists.slice();
+ }
+
+ var exact = [];
+ var prefix = [];
+ for (var i = 0; i < playlists.length; i++) {
+ var playlist = playlists[i];
+ var nameLower = (playlist.name || "").toLowerCase();
+ if (nameLower === queryLower) {
+ exact.push(playlist);
+ } else if (nameLower.indexOf(queryLower) === 0) {
+ prefix.push(playlist);
+ }
+ }
+
+ return exact.concat(prefix);
+ }
+
+ function currentPlaylistRenameTarget() {
+ var playlists = mainInstance?.playlistEntries || [];
+ for (var i = 0; i < playlists.length; i++) {
+ if ((playlists[i].id || "") === (playlistRenameId || "")) {
+ return playlists[i];
+ }
+ }
+ return null;
+ }
+
+ function playlistNameTaken(targetPlaylistId, name) {
+ var targetName = (name || "").trim().toLowerCase();
+ if (targetName.length === 0) {
+ return false;
+ }
+
+ var playlists = mainInstance?.playlistEntries || [];
+ for (var i = 0; i < playlists.length; i++) {
+ if ((playlists[i].id || "") === (targetPlaylistId || "")) {
+ continue;
+ }
+ if ((playlists[i].name || "").trim().toLowerCase() === targetName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function buildPlaylistHeaderItem(playlist) {
+ var playlistId = playlist.id || "";
+ var playlistName = playlist.name || pluginApi?.tr("playlists.untitled");
+ var entryCount = (playlist.entryIds || []).length;
+ var sourceType = (playlist.sourceType || "").trim();
+ var sourceFolder = (playlist.sourceFolder || "").trim();
+ var description = entryCount === 1 ? (pluginApi?.tr("playlists.oneTrack")) : (pluginApi?.tr("playlists.trackCount", {"count": entryCount}));
+ if (sourceType === "folder" && sourceFolder.length > 0) {
+ description = pluginApi?.tr("playlists.syncedFolderDescription", {
+ "count": description,
+ "source": pluginApi?.tr("playlists.syncedFolder")
+ });
+ }
+
+ return {
+ "id": playlistId,
+ "name": playlistName,
+ "description": description,
+ "icon": "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-header",
+ "sourceType": sourceType,
+ "sourceFolder": sourceFolder,
+ "onActivate": function () {
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:" + playlistName);
+ }
+ }
+ };
+ }
+
+ function buildPlaylistRenameHeaderItem(playlist) {
+ return {
+ "id": playlist?.id || "playlist-rename",
+ "name": pluginApi?.tr("playlists.rename"),
+ "description": playlist?.name || playlistRenameTitle || pluginApi?.tr("playlists.untitled"),
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-rename-header",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildPlaylistRenameHintItem(message) {
+ return {
+ "name": pluginApi?.tr("playlists.renameTitle"),
+ "description": message,
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-rename-hint",
+ "onActivate": function () {}
+ };
+ }
+
+ function buildPlaylistRenameItem(playlist, name) {
+ var targetName = (name || "").trim();
+ return {
+ "id": (playlist?.id || "") + ":rename:" + targetName.toLowerCase(),
+ "name": pluginApi?.tr("playlists.renameTo", {"name": targetName}),
+ "description": pluginApi?.tr("playlists.updateTitle"),
+ "icon": "pencil",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-rename",
+ "onActivate": function () {
+ mainInstance?.renamePlaylist((playlist?.id || ""), targetName);
+ root.clearPlaylistRename();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:" + targetName);
+ }
+ }
+ };
+ }
+
+ function buildPlaylistTrackItem(entry, playlist) {
+ var playlistName = playlist.name || pluginApi?.tr("playlists.untitled");
+ var playlistId = playlist.id || "";
+ return buildLibraryResultItem(entry, {
+ "prefix": playlistName,
+ "icon": entry?.id === mainInstance?.currentEntryId && mainInstance?.isPlaying ? "disc" : "music",
+ "playlistId": playlistId
+ });
+ }
+
+ function buildCreatePlaylistItem(playlistName) {
+ var targetName = (playlistName || "").trim();
+ var pendingEntryId = playlistPickerEntryId;
+ var pendingEntryTitle = playlistPickerEntryTitle;
+
+ return {
+ "name": pendingEntryId ? (pluginApi?.tr("playlists.createAndAdd", {"name": targetName})) : (pluginApi?.tr("playlists.create", {"name": targetName})),
+ "description": pendingEntryId ? (pluginApi?.tr("playlists.addAfterCreate", {"title": pendingEntryTitle})) : (pluginApi?.tr("playlists.createNew")),
+ "icon": "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-create",
+ "onActivate": function () {
+ mainInstance?.createPlaylist(targetName, pendingEntryId);
+ root.clearPlaylistSelection();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:" + targetName);
+ }
+ }
+ };
+ }
+
+ function buildPlaylistPickerItem(playlist) {
+ var playlistId = playlist.id || "";
+ var playlistName = playlist.name || pluginApi?.tr("playlists.untitled");
+ var entryCount = (playlist.entryIds || []).length;
+ var pendingEntryId = playlistPickerEntryId;
+
+ return {
+ "id": playlistId,
+ "name": pluginApi?.tr("playlists.addTo", {"name": playlistName}),
+ "description": entryCount === 1 ? (pluginApi?.tr("playlists.oneTrack")) : (pluginApi?.tr("playlists.trackCount", {"count": entryCount})),
+ "icon": "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "kind": "playlist-select",
+ "onActivate": function () {
+ mainInstance?.addToPlaylist(playlistId, pendingEntryId);
+ root.clearPlaylistSelection();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:" + playlistName);
+ }
+ }
+ };
+ }
+
+ function buildPlaylistRenameItems(playlistQuery) {
+ var playlist = currentPlaylistRenameTarget();
+ if (!playlist) {
+ return [
+ buildPlaylistRenameHintItem(pluginApi?.tr("playlists.chooseFirst"))
+ ];
+ }
+
+ var items = [buildPlaylistRenameHeaderItem(playlist)];
+ var targetName = (playlistQuery || "").trim();
+ if (targetName.length === 0) {
+ items.push(buildPlaylistRenameHintItem(pluginApi?.tr("playlists.typeNewName", {"name": playlist.name || playlistRenameTitle || pluginApi?.tr("playlists.untitled")})));
+ return items;
+ }
+
+ if ((playlist.name || "").trim().toLowerCase() === targetName.toLowerCase()) {
+ items.push(buildPlaylistRenameHintItem(pluginApi?.tr("playlists.typeDifferent")));
+ return items;
+ }
+
+ if (playlistNameTaken(playlist.id, targetName)) {
+ items.push(buildPlaylistRenameHintItem(pluginApi?.tr("playlists.alreadyExists", {"name": targetName})));
+ return items;
+ }
+
+ items.push(buildPlaylistRenameItem(playlist, targetName));
+ return items;
+ }
+
+ function buildPlaylistItems(playlistQuery) {
+ var playlists = mainInstance?.playlistEntries || [];
+ var library = mainInstance?.libraryEntries || [];
+ var items = [];
+
+ if (playlistQuery.length === 0) {
+ if (playlists.length === 0) {
+ items.push({
+ "name": pluginApi?.tr("playlists.none"),
+ "description": pluginApi?.tr("playlists.createHint"),
+ "icon": "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ });
+ } else {
+ for (var i = 0; i < playlists.length; i++) {
+ items.push(buildPlaylistHeaderItem(playlists[i]));
+ }
+ }
+ return items;
+ }
+
+ var matches = findPlaylistMatches(playlistQuery);
+ var targetPlaylist = matches.length > 0 ? matches[0] : null;
+
+ if (!targetPlaylist) {
+ items.push(buildCreatePlaylistItem(playlistQuery));
+ return items;
+ }
+
+ items.push(buildPlaylistHeaderItem(targetPlaylist));
+
+ var entryIds = targetPlaylist.entryIds || [];
+ for (var m = 0; m < entryIds.length; m++) {
+ var entryId = entryIds[m];
+ for (var n = 0; n < library.length; n++) {
+ if (library[n].id === entryId) {
+ items.push(buildPlaylistTrackItem(library[n], targetPlaylist));
+ break;
+ }
+ }
+ }
+
+ return items;
+ }
+
+ function buildPlaylistPickerItems(playlistQuery) {
+ var items = [];
+ var matches = findPlaylistMatches(playlistQuery);
+ var queryText = (playlistQuery || "").trim();
+ var exactMatch = false;
+
+ if (matches.length > 0) {
+ for (var i = 0; i < matches.length; i++) {
+ if ((matches[i].name || "").toLowerCase() === queryText.toLowerCase()) {
+ exactMatch = true;
+ }
+ items.push(buildPlaylistPickerItem(matches[i]));
+ }
+ }
+
+ if (queryText.length > 0 && !exactMatch) {
+ items.unshift(buildCreatePlaylistItem(queryText));
+ }
+
+ if (items.length === 0) {
+ items.push({
+ "name": pluginApi?.tr("playlists.choose"),
+ "description": pluginApi?.tr("playlists.chooseFor", {"title": playlistPickerEntryTitle}),
+ "icon": "playlist",
+ "isTablerIcon": true,
+ "isImage": false,
+ "provider": root,
+ "onActivate": function () {}
+ });
+ }
+
+ return items;
+ }
+
+ function buildSearchResultItem(entry) {
+ var saved = mainInstance?.isSaved(entry) === true;
+ var badge = saved ? "bookmark-filled" : "";
+ var entryProvider = (entry.provider || entry.providerName || mainInstance?.currentProvider || "youtube");
+
+ return {
+ "id": entry.id || "",
+ "name": entry.title || pluginApi?.tr("common.untitled"),
+ "description": buildDescription(entry, mainInstance?.providerLabel(entryProvider) || pluginApi?.tr("providers.youtube")),
+ "icon": "music",
+ "isTablerIcon": true,
+ "isImage": false,
+ "badgeIcon": badge,
+ "provider": root,
+ "kind": "search",
+ "url": entry.url || "",
+ "uploader": entry.uploader || "",
+ "duration": entry.duration || 0,
+ "providerName": entryProvider,
+ "album": entry.album || "",
+ "localPath": entry.localPath || "",
+ "playCount": entry.playCount || 0,
+ "lastPlayedAt": entry.lastPlayedAt || "",
+ "onActivate": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playEntry(entry);
+ }
+ };
+ }
+
+ function canSaveAsMp3(item) {
+ if (!item || !item.url) {
+ return false;
+ }
+ return mainInstance?.isLocalEntry(item) !== true;
+ }
+
+ function getItemActions(item) {
+ if (!item) {
+ return [];
+ }
+
+ if (item.kind === "queue-entry") {
+ return [
+ {
+ "icon": "player-play",
+ "tooltip": pluginApi?.tr("tooltip.playNow"),
+ "action": function () {
+ mainInstance?.playQueueEntryNow(item);
+ if (launcher) {
+ launcher.close();
+ }
+ }
+ },
+ {
+ "icon": "x",
+ "tooltip": pluginApi?.tr("tooltip.removeFromQueue"),
+ "action": function () {
+ mainInstance?.removeQueueEntry(item.id, true);
+ }
+ }
+ ];
+ }
+
+ if (item.kind === "status-idle") {
+ return [
+ {
+ "icon": "arrows-sort",
+ "tooltip": pluginApi?.tr("tooltip.sort", {"sort": mainInstance?.sortLabel() || pluginApi?.tr("sort.date")}),
+ "action": function () {
+ mainInstance?.cycleSortBy();
+ }
+ },
+ {
+ "icon": "switch-horizontal",
+ "tooltip": pluginApi?.tr("tooltip.switchProvider", {"provider": mainInstance?.providerLabel() || pluginApi?.tr("providers.youtube")}),
+ "action": function () {
+ mainInstance?.cycleProvider();
+ }
+ }
+ ];
+ }
+
+ if (item.kind === "status") {
+ if (!item.url) {
+ return [];
+ }
+
+ var statusLibraryEntry = mainInstance?.findLibraryEntry(item);
+ var statusSavedEntry = mainInstance?.findSavedEntry(item);
+ var statusActions = [
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.addToQueue"),
+ "action": function () {
+ mainInstance?.enqueueEntry(item);
+ }
+ }
+ ];
+
+ if (!statusSavedEntry) {
+ statusActions.unshift({
+ "icon": "bookmark-plus",
+ "tooltip": pluginApi?.tr("tooltip.saveToLibrary"),
+ "action": function () {
+ mainInstance?.saveEntry(statusLibraryEntry || item);
+ }
+ });
+ }
+
+ if (canSaveAsMp3(statusLibraryEntry || item)) {
+ statusActions.splice(statusActions.length > 0 ? 1 : 0, 0, {
+ "icon": "download",
+ "tooltip": pluginApi?.tr("tooltip.saveMp3Current"),
+ "action": function () {
+ mainInstance?.downloadCurrentTrack();
+ }
+ });
+ }
+
+ if (mainInstance?.isPaused) {
+ statusActions.unshift({
+ "icon": "player-play",
+ "tooltip": pluginApi?.tr("tooltip.resume"),
+ "action": function () {
+ mainInstance?.resumePlayback();
+ }
+ });
+ } else {
+ statusActions.unshift({
+ "icon": "player-pause",
+ "tooltip": pluginApi?.tr("tooltip.pause"),
+ "action": function () {
+ mainInstance?.pausePlayback();
+ }
+ });
+ }
+
+ if (statusLibraryEntry) {
+ statusActions.push({
+ "icon": "pencil",
+ "tooltip": pluginApi?.tr("tooltip.editMetadata"),
+ "action": function () {
+ root.startMetadataEditing(statusLibraryEntry, "");
+ }
+ });
+ }
+
+ if (statusSavedEntry) {
+ statusActions.push({
+ "icon": "tag",
+ "tooltip": pluginApi?.tr("tooltip.manageTags"),
+ "action": function () {
+ root.startTagEditing(statusSavedEntry);
+ }
+ });
+ statusActions.push({
+ "icon": "playlist",
+ "tooltip": pluginApi?.tr("tooltip.addSavedToPlaylist"),
+ "action": function () {
+ root.startPlaylistSelection(statusSavedEntry);
+ }
+ });
+ }
+
+ statusActions.push({
+ "icon": "switch-horizontal",
+ "tooltip": pluginApi?.tr("tooltip.switchProvider", {"provider": mainInstance?.providerLabel() || pluginApi?.tr("providers.youtube")}),
+ "action": function () {
+ mainInstance?.cycleProvider();
+ }
+ });
+
+ return statusActions;
+ }
+
+ if (item.kind === "search") {
+ var savedEntry = mainInstance?.findSavedEntry(item);
+ if (savedEntry) {
+ var savedActions = [
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.addToQueue"),
+ "action": function () {
+ mainInstance?.enqueueEntry(item);
+ }
+ },
+ {
+ "icon": "pencil",
+ "tooltip": pluginApi?.tr("tooltip.editMetadata"),
+ "action": function () {
+ root.startMetadataEditing(savedEntry, "");
+ }
+ },
+ {
+ "icon": "tag",
+ "tooltip": pluginApi?.tr("tooltip.manageTags"),
+ "action": function () {
+ root.startTagEditing(savedEntry);
+ }
+ },
+ {
+ "icon": "playlist",
+ "tooltip": pluginApi?.tr("tooltip.addToPlaylist"),
+ "action": function () {
+ root.startPlaylistSelection(savedEntry);
+ }
+ },
+ {
+ "icon": "bookmark-off",
+ "tooltip": pluginApi?.tr("tooltip.removeFromLibrary"),
+ "action": function () {
+ mainInstance?.removeEntry(savedEntry.id);
+ }
+ }
+ ];
+ if (canSaveAsMp3(savedEntry)) {
+ savedActions.splice(1, 0, {
+ "icon": "download",
+ "tooltip": pluginApi?.tr("tooltip.saveMp3"),
+ "action": function () {
+ mainInstance?.downloadEntry(item);
+ }
+ });
+ }
+ return savedActions;
+ }
+
+ var searchActions = [
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.addToQueue"),
+ "action": function () {
+ mainInstance?.enqueueEntry(item);
+ }
+ },
+ {
+ "icon": "bookmark-plus",
+ "tooltip": pluginApi?.tr("tooltip.saveToLibrary"),
+ "action": function () {
+ mainInstance?.saveEntry(item);
+ }
+ },
+ {
+ "icon": "playlist",
+ "tooltip": pluginApi?.tr("tooltip.saveFirst"),
+ "action": function () {
+ mainInstance?.saveEntry(item);
+ }
+ }
+ ];
+ if (canSaveAsMp3(item)) {
+ searchActions.splice(2, 0, {
+ "icon": "download",
+ "tooltip": pluginApi?.tr("tooltip.saveMp3"),
+ "action": function () {
+ mainInstance?.downloadEntry(item);
+ }
+ });
+ }
+ return searchActions;
+ }
+
+ if (item.kind === "library") {
+ var ratingLabel = (item.rating || 0) > 0 ? formatRating(item.rating || 0) : pluginApi?.tr("tooltip.unrated");
+ var libraryActions = [
+ {
+ "icon": "star",
+ "tooltip": pluginApi?.tr("tooltip.rate", {"rating": ratingLabel}),
+ "action": function () {
+ mainInstance?.cycleRating(item.id);
+ }
+ },
+ {
+ "icon": "pencil",
+ "tooltip": pluginApi?.tr("tooltip.editMetadata"),
+ "action": function () {
+ root.startMetadataEditing(item, "");
+ }
+ },
+ {
+ "icon": "tag",
+ "tooltip": pluginApi?.tr("tooltip.manageTags"),
+ "action": function () {
+ root.startTagEditing(item);
+ }
+ },
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.addToQueue"),
+ "action": function () {
+ mainInstance?.enqueueEntry(item);
+ }
+ },
+ {
+ "icon": "playlist",
+ "tooltip": pluginApi?.tr("tooltip.addToPlaylist"),
+ "action": function () {
+ root.startPlaylistSelection(item);
+ }
+ }
+ ];
+
+ if (canSaveAsMp3(item)) {
+ libraryActions.push({
+ "icon": "download",
+ "tooltip": pluginApi?.tr("tooltip.saveMp3"),
+ "action": function () {
+ mainInstance?.downloadEntry(item);
+ }
+ });
+ }
+
+ if (item.playlistId) {
+ libraryActions.push({
+ "icon": "playlist-x",
+ "tooltip": pluginApi?.tr("tooltip.removeFromPlaylist"),
+ "action": function () {
+ mainInstance?.removeFromPlaylist(item.playlistId, item.id);
+ }
+ });
+ }
+
+ libraryActions.push({
+ "icon": "bookmark-off",
+ "tooltip": pluginApi?.tr("tooltip.removeFromLibrary"),
+ "action": function () {
+ mainInstance?.removeEntry(item.id);
+ }
+ });
+
+ return libraryActions;
+ }
+
+ if (item.kind === "playlist-header") {
+ var playlistActions = [
+ {
+ "icon": "player-play",
+ "tooltip": pluginApi?.tr("tooltip.playPlaylist"),
+ "action": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playPlaylist(item.id, false);
+ }
+ },
+ {
+ "icon": "arrows-shuffle",
+ "tooltip": pluginApi?.tr("tooltip.shufflePlay"),
+ "action": function () {
+ if (launcher) {
+ launcher.close();
+ }
+ mainInstance?.playPlaylist(item.id, true);
+ }
+ },
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.queuePlaylist"),
+ "action": function () {
+ mainInstance?.queuePlaylist(item.id, false);
+ }
+ },
+ {
+ "icon": "pencil",
+ "tooltip": pluginApi?.tr("tooltip.renamePlaylist"),
+ "action": function () {
+ root.startPlaylistRename(item);
+ }
+ },
+ {
+ "icon": "trash",
+ "tooltip": pluginApi?.tr("tooltip.deletePlaylist"),
+ "action": function () {
+ root.clearPlaylistRename();
+ root.clearPlaylistSelection();
+ if (launcher) {
+ launcher.setSearchText(commandName + " playlist:");
+ }
+ mainInstance?.deletePlaylist(item.id);
+ }
+ }
+ ];
+
+ if ((item.sourceType || "") === "folder" && (item.sourceFolder || "").trim().length > 0) {
+ playlistActions.splice(3, 0, {
+ "icon": "refresh",
+ "tooltip": pluginApi?.tr("tooltip.syncFolder"),
+ "action": function () {
+ mainInstance?.syncFolderPlaylist(item.id);
+ }
+ });
+ }
+
+ return playlistActions;
+ }
+
+ if (item.kind === "custom-url") {
+ return [
+ {
+ "icon": "playlist-add",
+ "tooltip": pluginApi?.tr("tooltip.addToQueue"),
+ "action": function () {
+ mainInstance?.enqueueUrl(item.url, pluginApi?.tr("common.queuedUrl"));
+ }
+ },
+ {
+ "icon": "bookmark-plus",
+ "tooltip": pluginApi?.tr("tooltip.saveUrlToLibrary"),
+ "action": function () {
+ mainInstance?.saveUrl(item.url);
+ }
+ },
+ {
+ "icon": "download",
+ "tooltip": pluginApi?.tr("tooltip.saveMp3"),
+ "action": function () {
+ mainInstance?.downloadUrl(item.url, pluginApi?.tr("common.downloadedTrack"));
+ }
+ }
+ ];
+ }
+
+ return [];
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/Main.qml b/.config/noctalia/plugins/music-search/Main.qml
new file mode 100644
index 0000000..b3d20e3
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/Main.qml
@@ -0,0 +1,2060 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings ?? ({})
+ readonly property string untitledLabel: pluginApi?.tr("common.untitled") ?? ""
+ readonly property string customUrlLabel: pluginApi?.tr("common.customUrl") ?? ""
+ readonly property string downloadedTrackLabel: pluginApi?.tr("common.downloadedTrack") ?? ""
+ readonly property string queuedUrlLabel: pluginApi?.tr("common.queuedUrl") ?? ""
+ readonly property string playlistEmptyNotice: pluginApi?.tr("notices.playlistEmpty") ?? ""
+
+ property bool isPlaying: false
+ property string currentEntryId: ""
+ property string currentTitle: ""
+ property string currentUrl: ""
+ property string currentUploader: ""
+ property int currentDuration: 0
+ property real currentSpeed: 1.0
+ property int currentPid: 0
+ property bool isPaused: false
+ property real currentPosition: 0
+ property string currentEndReason: ""
+ property string currentUpdatedAt: "0"
+ property string lastError: ""
+ property string lastNotice: ""
+
+ property string currentProvider: "youtube"
+ property string currentSortBy: "date"
+ property string downloadDirectory: Quickshell.env("HOME") + "/Music/Noctalia"
+ property int downloadCacheMaxMb: 0
+ readonly property string previewMetadataMode: pluginApi?.pluginSettings?.previewMetadataMode
+ ?? root.defaults.previewMetadataMode
+ ?? "always"
+ readonly property bool showUploaderMetadata: pluginApi?.pluginSettings?.showUploaderMetadata
+ ?? root.defaults.showUploaderMetadata
+ ?? true
+ readonly property bool showAlbumMetadata: pluginApi?.pluginSettings?.showAlbumMetadata
+ ?? root.defaults.showAlbumMetadata
+ ?? true
+ readonly property bool showDurationMetadata: pluginApi?.pluginSettings?.showDurationMetadata
+ ?? root.defaults.showDurationMetadata
+ ?? true
+ readonly property bool showRatingMetadata: pluginApi?.pluginSettings?.showRatingMetadata
+ ?? root.defaults.showRatingMetadata
+ ?? true
+ readonly property bool showTagMetadata: pluginApi?.pluginSettings?.showTagMetadata
+ ?? root.defaults.showTagMetadata
+ ?? true
+ readonly property bool showPlayStatsMetadata: pluginApi?.pluginSettings?.showPlayStatsMetadata
+ ?? root.defaults.showPlayStatsMetadata
+ ?? true
+ readonly property bool showStatusMetadata: pluginApi?.pluginSettings?.showStatusMetadata
+ ?? root.defaults.showStatusMetadata
+ ?? true
+ readonly property bool showPreviewChips: pluginApi?.pluginSettings?.showPreviewChips
+ ?? root.defaults.showPreviewChips
+ ?? true
+ readonly property string previewThumbnailSize: pluginApi?.pluginSettings?.previewThumbnailSize
+ ?? root.defaults.previewThumbnailSize
+ ?? "comfortable"
+ readonly property bool showHomeRecent: pluginApi?.pluginSettings?.showHomeRecent
+ ?? root.defaults.showHomeRecent
+ ?? true
+ readonly property bool showHomeTop: pluginApi?.pluginSettings?.showHomeTop
+ ?? root.defaults.showHomeTop
+ ?? true
+ readonly property bool showHomeTags: pluginApi?.pluginSettings?.showHomeTags
+ ?? root.defaults.showHomeTags
+ ?? true
+ readonly property bool showHomeArtists: pluginApi?.pluginSettings?.showHomeArtists
+ ?? root.defaults.showHomeArtists
+ ?? true
+ readonly property bool showHomePlaylists: pluginApi?.pluginSettings?.showHomePlaylists
+ ?? root.defaults.showHomePlaylists
+ ?? true
+ readonly property bool autoSaveMp3AfterPlayback: pluginApi?.pluginSettings?.autoSaveMp3AfterPlayback
+ ?? root.defaults.autoSaveMp3AfterPlayback
+ ?? false
+ property string ytPlayerClient: "android"
+
+ property var libraryEntries: []
+ property var playlistEntries: []
+ property var queueEntries: []
+ property string lastQueueSnapshot: ""
+ property bool queueActive: false
+ property string pendingPlaylistCreateName: ""
+ property string pendingPlaylistCreateEntryId: ""
+ property string pendingQueueLaunchEntryId: ""
+ property var pendingQueueLaunchEntry: null
+ property string lastHandledQueueUpdate: ""
+ property bool pendingAutoplaySaved: false
+ property bool pendingAutoplaySavedShuffle: false
+ property bool queueBatchBusy: false
+ property var queueBatchEntries: []
+ property int queueBatchIndex: 0
+ property string queueBatchFinalAction: ""
+ property string queueBatchDoneNotice: ""
+ property string queueControlAction: ""
+ property string queueCommandAction: ""
+ property bool queueLaunchBusy: false
+
+ property bool playbackBusy: false
+ property string playbackCommandAction: ""
+ property bool playbackStarting: false
+ property string playbackStartingProvider: ""
+ property string playbackStartingMessage: ""
+ property bool pendingPlaybackFailureFallback: false
+ property bool statusBusy: false
+ property bool libraryBusy: false
+ property bool downloadBusy: false
+ property bool queueBusy: false
+ property bool pauseBusy: false
+ property bool positionBusy: false
+ property bool seekBusy: false
+ property bool speedBusy: false
+ property bool providerBusy: false
+ property string providerSuccessNotice: ""
+ property bool playlistsBusy: false
+ property bool importBusy: false
+ property real pendingSeekPosition: -1
+
+ readonly property string helperPath: Qt.resolvedUrl("musicctl.sh").toString().replace("file://", "")
+ readonly property string cacheDir: Quickshell.env("MUSIC_CACHE_DIR")
+ || ((Quickshell.env("XDG_CACHE_HOME") || (Quickshell.env("HOME") + "/.cache")) + "/noctalia/plugins/music-search")
+ readonly property string statePath: root.cacheDir + "/state.json"
+ readonly property string libraryPath: root.cacheDir + "/library.json"
+ readonly property string settingsPath: root.cacheDir + "/settings.json"
+ readonly property string playlistsPath: root.cacheDir + "/playlists.json"
+ readonly property string queuePath: root.cacheDir + "/queue.json"
+
+ Component.onCompleted: root.refreshStatus()
+ onPluginApiChanged: root.refreshStatus()
+
+ FileView {
+ id: stateFile
+ path: root.statePath
+ watchChanges: true
+ printErrors: false
+
+ onLoaded: root.applyState(text())
+ onTextChanged: root.applyState(text())
+ }
+
+ FileView {
+ id: libraryFile
+ path: root.libraryPath
+ watchChanges: true
+ printErrors: false
+
+ onLoaded: root.applyLibrary(text())
+ onTextChanged: root.applyLibrary(text())
+ }
+
+ FileView {
+ id: settingsFile
+ path: root.settingsPath
+ watchChanges: true
+ printErrors: false
+
+ onLoaded: root.applySettings(text())
+ onTextChanged: root.applySettings(text())
+ }
+
+ FileView {
+ id: playlistsFile
+ path: root.playlistsPath
+ watchChanges: true
+ printErrors: false
+
+ onLoaded: root.applyPlaylists(text())
+ onTextChanged: root.applyPlaylists(text())
+ }
+
+ FileView {
+ id: queueFile
+ path: root.queuePath
+ watchChanges: true
+ printErrors: false
+
+ onLoaded: root.applyQueue(text())
+ onTextChanged: root.applyQueue(text())
+ }
+
+ Timer {
+ id: statusTimer
+ interval: 5000
+ repeat: true
+ running: true
+ triggeredOnStart: true
+
+ onTriggered: root.refreshStatus()
+ }
+
+ Process {
+ id: playbackProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ var commandAction = root.playbackCommandAction || "";
+ root.playbackCommandAction = "";
+ root.playbackBusy = false;
+ root.playbackStarting = false;
+ root.playbackStartingProvider = "";
+ root.playbackStartingMessage = "";
+
+ var output = String(playbackProcess.stdout.text || "").trim();
+ var stderrText = String(playbackProcess.stderr.text || "").trim();
+
+ if (output.length > 0) {
+ root.applyState(output);
+ } else if (stderrText.length > 0 && exitCode !== 0) {
+ root.lastError = stderrText;
+ }
+
+ if (commandAction === "play" && exitCode === 0 && root.autoSaveMp3AfterPlayback) {
+ root.maybeAutoDownloadCurrentTrack();
+ }
+
+ if (commandAction === "play" && exitCode !== 0) {
+ root.queueLaunchBusy = false;
+ }
+
+ if (commandAction === "play" && exitCode !== 0 && stderrText.length === 0) {
+ root.pendingPlaybackFailureFallback = true;
+ } else {
+ root.pendingPlaybackFailureFallback = false;
+ }
+
+ if (exitCode !== 0 && root.lastError === "" && !root.pendingPlaybackFailureFallback) {
+ root.lastError = root.pluginApi?.tr("errors.playbackFailed");
+ }
+
+ root.refreshStatus(true);
+ }
+ }
+
+ Process {
+ id: statusProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function () {
+ root.statusBusy = false;
+ root.applyState(String(statusProcess.stdout.text || "").trim());
+
+ if (root.pendingPlaybackFailureFallback) {
+ if (!root.isPlaying) {
+ root.lastError = root.pluginApi?.tr("errors.playbackFailed");
+ }
+ root.pendingPlaybackFailureFallback = false;
+ }
+ }
+ }
+
+ Process {
+ id: libraryProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.libraryBusy = false;
+
+ var output = String(libraryProcess.stdout.text || "").trim();
+ var stderrText = String(libraryProcess.stderr.text || "").trim();
+ if (exitCode === 0 && output.length > 0) {
+ root.applyLibrary(output);
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("notices.libraryUpdated");
+ } else if (exitCode !== 0 && stderrText.length > 0) {
+ root.lastNotice = "";
+ root.lastError = stderrText;
+ } else if (exitCode !== 0 && root.lastError === "") {
+ root.lastNotice = "";
+ root.lastError = root.pluginApi?.tr("errors.libraryFailed");
+ }
+ }
+ }
+
+ Process {
+ id: downloadProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.downloadBusy = false;
+
+ var output = String(downloadProcess.stdout.text || "").trim();
+ var stderrText = String(downloadProcess.stderr.text || "").trim();
+
+ if (exitCode === 0) {
+ root.lastError = "";
+ root.lastNotice = output.length > 0 ? (root.pluginApi?.tr("notices.savedMp3To", {"path": output})) : (root.pluginApi?.tr("notices.savedMp3"));
+ } else {
+ root.lastNotice = "";
+ root.lastError = stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.mp3SaveFailed"));
+ }
+ }
+ }
+
+ Process {
+ id: queueProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.queueBusy = false;
+
+ var action = root.queueCommandAction || "";
+ root.queueCommandAction = "";
+ var output = String(queueProcess.stdout.text || "").trim();
+ var stderrText = String(queueProcess.stderr.text || "").trim();
+
+ if (exitCode === 0) {
+ if (output.length > 0) {
+ root.applyQueue(output);
+ }
+ root.lastError = "";
+ if (action === "enqueue") {
+ root.lastNotice = root.pluginApi?.tr("notices.addedToQueue");
+ } else if (action === "remove") {
+ root.lastNotice = root.pluginApi?.tr("queue.removed");
+ } else if (action === "remove-silent") {
+ root.lastNotice = "";
+ } else if (action === "clear") {
+ root.lastNotice = root.pluginApi?.tr("queue.cleared");
+ } else if (action === "load-library") {
+ if (root.pendingAutoplaySaved || root.pendingAutoplaySavedShuffle) {
+ var shouldShuffle = root.pendingAutoplaySavedShuffle;
+ root.pendingAutoplaySaved = false;
+ root.pendingAutoplaySavedShuffle = false;
+ if (root.queueEntries.length > 0) {
+ root.startQueue();
+ if (!root.isPlaying) {
+ root.lastNotice = shouldShuffle ? root.pluginApi?.tr("queue.playingShuffledSaved") : root.pluginApi?.tr("queue.playingSaved");
+ }
+ } else {
+ root.lastNotice = root.pluginApi?.tr("queue.savedEmpty");
+ }
+ } else {
+ root.lastNotice = root.queueEntries.length > 0 ? root.pluginApi?.tr("queue.loaded") : root.pluginApi?.tr("queue.loadedEmpty");
+ }
+ } else {
+ root.lastNotice = "";
+ }
+ } else {
+ root.pendingAutoplaySaved = false;
+ root.pendingAutoplaySavedShuffle = false;
+ root.lastNotice = "";
+ root.lastError = stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.queueFailed"));
+ }
+ }
+ }
+
+ Process {
+ id: queueBatchProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ var output = String(queueBatchProcess.stdout.text || "").trim();
+ var stderrText = String(queueBatchProcess.stderr.text || "").trim();
+ if (exitCode !== 0) {
+ root.finishQueueBatch(stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.queuePlaylistTrackFailed")), "");
+ return;
+ }
+
+ if (output.length > 0) {
+ root.applyQueue(output);
+ }
+ root.queueBatchIndex += 1;
+ root.runNextQueueBatchStep();
+ }
+ }
+
+ Process {
+ id: queueControlProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ var action = root.queueControlAction;
+ var stderrText = String(queueControlProcess.stderr.text || "").trim();
+ root.queueControlAction = "";
+
+ if (!root.queueBatchBusy) {
+ if (exitCode !== 0 && stderrText.length > 0) {
+ root.lastError = stderrText;
+ }
+ return;
+ }
+
+ if (exitCode !== 0) {
+ root.finishQueueBatch(stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.queueActionFailed", {"action": action})), "");
+ return;
+ }
+
+ if (action === "clear") {
+ var output = String(queueControlProcess.stdout.text || "").trim();
+ if (output.length > 0) {
+ root.applyQueue(output);
+ }
+ root.runNextQueueBatchStep();
+ return;
+ }
+ }
+ }
+
+ Process {
+ id: pauseProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.pauseBusy = false;
+
+ var output = String(pauseProcess.stdout.text || "").trim();
+ var stderrText = String(pauseProcess.stderr.text || "").trim();
+
+ if (output.length > 0) {
+ root.applyState(output);
+ } else if (stderrText.length > 0 && exitCode !== 0) {
+ root.lastError = stderrText;
+ }
+ }
+ }
+
+ Process {
+ id: positionProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function () {
+ root.positionBusy = false;
+ var output = String(positionProcess.stdout.text || "").trim();
+ if (output.length > 0) {
+ try {
+ var parsed = JSON.parse(output);
+ root.currentPosition = parsed.position || 0;
+ } catch (error) {}
+ }
+ }
+ }
+
+ Process {
+ id: seekProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.seekBusy = false;
+
+ var output = String(seekProcess.stdout.text || "").trim();
+ var stderrText = String(seekProcess.stderr.text || "").trim();
+
+ if (exitCode === 0 && output.length > 0) {
+ try {
+ var parsed = JSON.parse(output);
+ root.currentPosition = parsed.position || 0;
+ root.lastError = "";
+ } catch (error) {
+ root.lastError = root.pluginApi?.tr("errors.seekMalformed");
+ }
+ } else if (exitCode !== 0) {
+ root.lastError = stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.seekFailed"));
+ }
+
+ var pendingSeek = root.pendingSeekPosition;
+ root.pendingSeekPosition = -1;
+
+ if (isFinite(pendingSeek) && pendingSeek >= 0) {
+ root.startSeek(pendingSeek);
+ return;
+ }
+
+ root.refreshStatus(true);
+ }
+ }
+
+ Process {
+ id: speedProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.speedBusy = false;
+
+ var output = String(speedProcess.stdout.text || "").trim();
+ var stderrText = String(speedProcess.stderr.text || "").trim();
+
+ if (exitCode === 0 && output.length > 0) {
+ root.applyState(output);
+ root.lastError = "";
+ } else if (exitCode !== 0) {
+ root.lastError = stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.speedFailed"));
+ }
+ }
+ }
+
+ Timer {
+ id: positionTimer
+ interval: 1000
+ repeat: true
+ running: root.isPlaying && !root.isPaused && !root.seekBusy
+
+ onTriggered: {
+ if (root.positionBusy) {
+ return;
+ }
+ root.positionBusy = true;
+ positionProcess.exec({
+ "command": root.buildCommand(["position"])
+ });
+ }
+ }
+
+ Process {
+ id: providerProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.providerBusy = false;
+
+ var output = String(providerProcess.stdout.text || "").trim();
+ if (exitCode === 0 && output.length > 0) {
+ root.applySettings(output);
+ root.lastError = "";
+ root.lastNotice = root.providerSuccessNotice.length > 0
+ ? root.providerSuccessNotice
+ : (root.pluginApi?.tr("notices.switchedTo", {"provider": root.providerLabel(root.currentProvider)}));
+ } else {
+ var stderrText = String(providerProcess.stderr.text || "").trim();
+ if (stderrText.length > 0) {
+ root.lastError = stderrText;
+ }
+ }
+ root.providerSuccessNotice = "";
+ }
+ }
+
+ Process {
+ id: playlistsProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.playlistsBusy = false;
+
+ var output = String(playlistsProcess.stdout.text || "").trim();
+ var stderrText = String(playlistsProcess.stderr.text || "").trim();
+ if (exitCode === 0 && output.length > 0) {
+ root.applyPlaylists(output);
+ root.lastError = "";
+ if (root.pendingPlaylistCreateName.length > 0 && root.pendingPlaylistCreateEntryId.length > 0) {
+ var createdPlaylist = root.findPlaylistByName(root.pendingPlaylistCreateName);
+ var pendingEntryId = root.pendingPlaylistCreateEntryId;
+ root.pendingPlaylistCreateName = "";
+ root.pendingPlaylistCreateEntryId = "";
+ if (createdPlaylist) {
+ root.addToPlaylist(createdPlaylist.id, pendingEntryId);
+ return;
+ }
+ } else {
+ root.pendingPlaylistCreateName = "";
+ root.pendingPlaylistCreateEntryId = "";
+ }
+ root.lastNotice = root.pluginApi?.tr("notices.playlistsUpdated");
+ } else if (exitCode !== 0 && stderrText.length > 0) {
+ root.pendingPlaylistCreateName = "";
+ root.pendingPlaylistCreateEntryId = "";
+ root.lastNotice = "";
+ root.lastError = stderrText;
+ }
+ }
+ }
+
+ Process {
+ id: importProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.importBusy = false;
+
+ var output = String(importProcess.stdout.text || "").trim();
+ var stderrText = String(importProcess.stderr.text || "").trim();
+ if (exitCode === 0) {
+ root.lastError = "";
+ root.lastNotice = output.length > 0 ? output : (root.pluginApi?.tr("notices.folderImported"));
+ } else {
+ root.lastNotice = "";
+ root.lastError = stderrText.length > 0 ? stderrText : (root.pluginApi?.tr("errors.folderImportFailed"));
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "plugin:music"
+
+ function toggle() {
+ if (root.isPlaying) {
+ root.stopPlayback();
+ } else {
+ root.openLauncher();
+ }
+ }
+
+ function launcher() {
+ root.openLauncher();
+ }
+
+ function panel() {
+ root.openPanel();
+ }
+
+ function togglePanel() {
+ root.togglePanelView();
+ }
+
+ function play(url: string) {
+ var target = (url || "").trim();
+ if (target.length === 0) {
+ return;
+ }
+ root.playUrl(target, root.customUrlLabel);
+ }
+
+ function playEntry(entryId: string, title: string, url: string, uploader: string, duration: real) {
+ var target = (url || "").trim();
+ if (target.length === 0) {
+ return;
+ }
+
+ root.playEntry({
+ "id": (entryId || "").trim(),
+ "title": (title || "").trim() || root.untitledLabel,
+ "url": target,
+ "uploader": (uploader || "").trim(),
+ "duration": duration || 0
+ });
+ }
+
+ function stop() {
+ root.stopPlayback();
+ }
+
+ function pause() {
+ root.pausePlayback();
+ }
+
+ function resume() {
+ root.resumePlayback();
+ }
+
+ function seek(position: real) {
+ root.seekToPosition(position);
+ }
+
+ function speed(value: real) {
+ root.setSpeed(value);
+ }
+
+ function setProvider(provider: string) {
+ root.setProvider((provider || "").trim());
+ }
+
+ function renamePlaylist(playlistId: string, name: string) {
+ root.renamePlaylist((playlistId || "").trim(), (name || "").trim());
+ }
+
+ function save(url: string) {
+ var target = (url || "").trim();
+ if (target.length === 0) {
+ return;
+ }
+ root.saveUrl(target);
+ }
+
+ function status() {
+ root.refreshStatus(true);
+ }
+ }
+
+ function buildCommand(extraArgs) {
+ var command = ["bash", root.helperPath];
+ for (var i = 0; i < extraArgs.length; i++) {
+ command.push(extraArgs[i]);
+ }
+ return command;
+ }
+
+ function buildQueueCommand(action, extraArgs) {
+ var command = ["queue-" + action];
+ for (var i = 0; i < extraArgs.length; i++) {
+ command.push(extraArgs[i]);
+ }
+ return buildCommand(command);
+ }
+
+ function textValue(value) {
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ return "";
+ }
+
+ function trimmedText(value) {
+ return textValue(value).trim();
+ }
+
+ function normalizeUrl(value) {
+ var trimmed = trimmedText(value);
+ if (trimmed.startsWith("www.")) {
+ return "https://" + trimmed;
+ }
+ return trimmed;
+ }
+
+ function inferProviderForEntry(entry) {
+ var explicitProvider = trimmedText(entry?.providerName || entry?.provider).toLowerCase();
+ if (explicitProvider === "youtube" || explicitProvider === "soundcloud" || explicitProvider === "local") {
+ return explicitProvider;
+ }
+
+ var entryUrl = trimmedText(entry?.url);
+ if (entryUrl.startsWith("/")) {
+ return "local";
+ }
+ if (entryUrl.indexOf("youtube.com") >= 0 || entryUrl.indexOf("youtu.be") >= 0) {
+ return "youtube";
+ }
+ if (entryUrl.indexOf("soundcloud.com") >= 0) {
+ return "soundcloud";
+ }
+ return root.currentProvider || "youtube";
+ }
+
+ function startupMessageForEntry(entry) {
+ var provider = inferProviderForEntry(entry);
+ if (provider === "local") {
+ return root.pluginApi?.tr("status.openingLocal");
+ }
+ if (provider === "soundcloud") {
+ return root.pluginApi?.tr("status.connectingSoundcloud");
+ }
+ return root.pluginApi?.tr("status.connectingYoutube");
+ }
+
+ function currentTrackEntry() {
+ return {
+ "id": root.currentEntryId || "",
+ "title": root.currentTitle || root.untitledLabel,
+ "url": root.currentUrl || "",
+ "uploader": root.currentUploader || "",
+ "duration": root.currentDuration || 0,
+ "provider": inferProviderForEntry({
+ "provider": root.currentProvider,
+ "url": root.currentUrl
+ })
+ };
+ }
+
+ function isLocalEntry(entry) {
+ return inferProviderForEntry(entry) === "local";
+ }
+
+ function isPlaceholderEntryTitle(title) {
+ var normalized = trimmedText(title).toLowerCase();
+ return normalized.length === 0
+ || normalized === root.customUrlLabel.toLowerCase()
+ || normalized === root.queuedUrlLabel.toLowerCase()
+ || normalized === root.downloadedTrackLabel.toLowerCase();
+ }
+
+ function playEntry(entry) {
+ if (!entry || !entry.url) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.pendingPlaybackFailureFallback = false;
+ root.playbackBusy = true;
+ root.playbackStarting = true;
+ root.playbackStartingProvider = inferProviderForEntry(entry);
+ root.playbackStartingMessage = startupMessageForEntry(entry);
+ root.isPlaying = false;
+ root.isPaused = false;
+ root.currentEntryId = entry.id || "";
+ root.currentTitle = entry.title || root.untitledLabel;
+ root.currentUrl = entry.url || "";
+ root.currentUploader = entry.uploader || "";
+ root.currentDuration = entry.duration || 0;
+ root.currentSpeed = root.clampSpeed(root.currentSpeed || 1);
+ root.currentPid = 0;
+ root.currentPosition = 0;
+ root.currentEndReason = "";
+ root.playbackCommandAction = "play";
+ playbackProcess.exec({
+ "command": buildCommand([
+ "play",
+ entry.id || "",
+ entry.title || root.untitledLabel,
+ entry.url || "",
+ entry.uploader || "",
+ String(entry.duration || 0)
+ ])
+ });
+ }
+
+ function playUrl(url, label) {
+ var cleanedUrl = normalizeUrl(url);
+ if (cleanedUrl.length === 0) {
+ return;
+ }
+
+ var customEntry = {
+ "id": "custom-" + Date.now(),
+ "title": label || root.customUrlLabel,
+ "url": cleanedUrl,
+ "uploader": "",
+ "duration": 0
+ };
+ playEntry(customEntry);
+ }
+
+ function stopPlayback() {
+ root.lastError = "";
+ root.lastNotice = "";
+ root.playbackBusy = true;
+ root.playbackStarting = false;
+ root.playbackStartingProvider = "";
+ root.playbackStartingMessage = "";
+ root.playbackCommandAction = "stop";
+ playbackProcess.exec({
+ "command": buildCommand(["stop"])
+ });
+ }
+
+ function pausePlayback() {
+ if (!root.isPlaying || root.pauseBusy) {
+ return;
+ }
+
+ root.pauseBusy = true;
+ pauseProcess.exec({
+ "command": buildCommand(["pause"])
+ });
+ }
+
+ function resumePlayback() {
+ if (!root.isPlaying || root.pauseBusy) {
+ return;
+ }
+
+ root.pauseBusy = true;
+ pauseProcess.exec({
+ "command": buildCommand(["resume"])
+ });
+ }
+
+ function togglePause() {
+ if (root.isPaused) {
+ resumePlayback();
+ } else {
+ pausePlayback();
+ }
+ }
+
+ function startSeek(position) {
+ var target = Number(position);
+ if (!isFinite(target) || target < 0) {
+ return;
+ }
+
+ root.seekBusy = true;
+ seekProcess.exec({
+ "command": buildCommand(["seek", String(target)])
+ });
+ }
+
+ function seekToPosition(position) {
+ if (!root.isPlaying) {
+ return;
+ }
+
+ var duration = root.currentDuration || 0;
+ var target = position;
+ if (!isFinite(duration) || duration <= 0 || !isFinite(target)) {
+ return;
+ }
+
+ target = Math.max(0, Math.min(duration, target));
+
+ if (root.seekBusy) {
+ root.pendingSeekPosition = target;
+ return;
+ }
+
+ root.pendingSeekPosition = -1;
+ startSeek(target);
+ }
+
+ function seekToRatio(ratio) {
+ var duration = root.currentDuration || 0;
+ var targetRatio = ratio;
+ if (!isFinite(duration) || duration <= 0 || !isFinite(targetRatio)) {
+ return;
+ }
+
+ seekToPosition(targetRatio * duration);
+ }
+
+ function clampSpeed(speed) {
+ var target = Number(speed);
+ if (!isFinite(target)) {
+ return 1;
+ }
+ return Math.max(0.25, Math.min(4, target));
+ }
+
+ function setSpeed(speed) {
+ if (!root.isPlaying || root.speedBusy) {
+ return;
+ }
+
+ var target = clampSpeed(speed);
+ root.speedBusy = true;
+ speedProcess.exec({
+ "command": buildCommand(["speed", String(target)])
+ });
+ }
+
+ function adjustSpeed(delta) {
+ setSpeed(clampSpeed(Number(root.currentSpeed || 1) + Number(delta || 0)));
+ }
+
+ function setProvider(provider) {
+ var target = String(provider || "").trim();
+ if (target.length === 0 || root.providerBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.providerBusy = true;
+ root.providerSuccessNotice = root.pluginApi?.tr("notices.switchedTo", {"provider": root.providerLabel(target)});
+ providerProcess.exec({
+ "command": buildCommand(["set-provider", target])
+ });
+ }
+
+ function cycleProvider() {
+ var providers = ["youtube", "soundcloud", "local"];
+ var currentIndex = providers.indexOf(root.currentProvider);
+ var nextIndex = (currentIndex + 1) % providers.length;
+ setProvider(providers[nextIndex]);
+ }
+
+ function providerLabel(provider) {
+ var p = provider || root.currentProvider;
+ if (p === "youtube") return root.pluginApi?.tr("providers.youtube");
+ if (p === "soundcloud") return root.pluginApi?.tr("providers.soundcloud");
+ if (p === "local") return root.pluginApi?.tr("providers.local");
+ return p;
+ }
+
+ function tagEntry(entryId, tag) {
+ var targetId = String(entryId || "").trim();
+ var targetTag = String(tag || "").trim();
+ if (targetId.length === 0 || targetTag.length === 0 || root.libraryBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["tag", targetId, targetTag])
+ });
+ }
+
+ function untagEntry(entryId, tag) {
+ var targetId = String(entryId || "").trim();
+ var targetTag = String(tag || "").trim();
+ if (targetId.length === 0 || targetTag.length === 0 || root.libraryBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["untag", targetId, targetTag])
+ });
+ }
+
+ function rateEntry(entryId, rating) {
+ var targetId = String(entryId || "").trim();
+ if (targetId.length === 0 || root.libraryBusy) {
+ return;
+ }
+
+ var r = Number(rating || 0);
+ if (r < 0) r = 0;
+ if (r > 5) r = 5;
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["rate", targetId, String(r)])
+ });
+ }
+
+ function cycleRating(entryId) {
+ var entry = null;
+ for (var i = 0; i < libraryEntries.length; i++) {
+ if (libraryEntries[i].id === entryId) {
+ entry = libraryEntries[i];
+ break;
+ }
+ }
+ var current = entry ? (entry.rating || 0) : 0;
+ var next = (current + 1) % 6;
+ rateEntry(entryId, next);
+ }
+
+ function setSortBy(sortBy) {
+ var target = String(sortBy || "").trim();
+ if (target.length === 0 || root.providerBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.providerBusy = true;
+ root.providerSuccessNotice = root.pluginApi?.tr("notices.sortSet", {"sort": root.sortLabel(target)});
+ providerProcess.exec({
+ "command": buildCommand(["set-sort", target])
+ });
+ }
+
+ function setDownloadDirectory(directory) {
+ var target = String(directory || "").trim();
+ if (target.length === 0 || root.providerBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.providerBusy = true;
+ root.providerSuccessNotice = root.pluginApi?.tr("notices.downloadFolderUpdated");
+ providerProcess.exec({
+ "command": buildCommand(["set-download-dir", target])
+ });
+ }
+
+ function setDownloadCacheMaxMb(value) {
+ var target = Math.max(0, Math.floor(Number(value || 0)));
+ if (!isFinite(target) || root.providerBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.providerBusy = true;
+ root.providerSuccessNotice = root.pluginApi?.tr("notices.cacheLimitUpdated");
+ providerProcess.exec({
+ "command": buildCommand(["set-cache-size", String(target)])
+ });
+ }
+
+ function setYtPlayerClient(client) {
+ var target = String(client || "").trim();
+ if (target.length === 0 || root.providerBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.providerBusy = true;
+ root.providerSuccessNotice = root.pluginApi?.tr("notices.ytClientSet", {"client": target});
+ providerProcess.exec({
+ "command": buildCommand(["set-yt-player-client", target])
+ });
+ }
+
+ function cycleSortBy() {
+ var modes = ["date", "title", "duration", "rating"];
+ var currentIndex = modes.indexOf(root.currentSortBy);
+ var nextIndex = (currentIndex + 1) % modes.length;
+ setSortBy(modes[nextIndex]);
+ }
+
+ function sortLabel(sortBy) {
+ var s = sortBy || root.currentSortBy;
+ if (s === "date") return root.pluginApi?.tr("sort.date");
+ if (s === "title") return root.pluginApi?.tr("sort.title");
+ if (s === "duration") return root.pluginApi?.tr("sort.duration");
+ if (s === "rating") return root.pluginApi?.tr("sort.rating");
+ return s;
+ }
+
+ function findPlaylistByName(name) {
+ var targetName = String(name || "").trim().toLowerCase();
+ if (targetName.length === 0) {
+ return null;
+ }
+
+ for (var i = playlistEntries.length - 1; i >= 0; i--) {
+ if (String(playlistEntries[i].name || "").trim().toLowerCase() === targetName) {
+ return playlistEntries[i];
+ }
+ }
+
+ return null;
+ }
+
+ function findPlaylistById(playlistId) {
+ var targetId = String(playlistId || "").trim();
+ if (targetId.length === 0) {
+ return null;
+ }
+
+ for (var i = 0; i < playlistEntries.length; i++) {
+ if (String(playlistEntries[i].id || "") === targetId) {
+ return playlistEntries[i];
+ }
+ }
+
+ return null;
+ }
+
+ function copyEntry(entry) {
+ return {
+ "id": entry?.id ?? "",
+ "title": entry?.title || entry?.name || root.untitledLabel,
+ "url": entry?.url ?? "",
+ "uploader": entry?.uploader ?? "",
+ "duration": entry?.duration ?? 0,
+ "provider": entry?.provider ?? "",
+ "album": entry?.album ?? "",
+ "localPath": entry?.localPath ?? "",
+ "isSaved": root.entrySavedState(entry),
+ "tags": entry?.tags || [],
+ "rating": entry?.rating ?? 0,
+ "playCount": entry?.playCount ?? 0,
+ "lastPlayedAt": entry?.lastPlayedAt ?? ""
+ };
+ }
+
+ function entrySavedState(entry) {
+ if (!entry) {
+ return false;
+ }
+
+ if (entry.isSaved === false) {
+ return false;
+ }
+
+ if (entry.isSaved === true) {
+ return true;
+ }
+
+ return root.isSaved(entry);
+ }
+
+ function shuffleEntries(entries) {
+ var shuffled = entries.slice();
+ for (var i = shuffled.length - 1; i > 0; i--) {
+ var j = Math.floor(Math.random() * (i + 1));
+ var tmp = shuffled[i];
+ shuffled[i] = shuffled[j];
+ shuffled[j] = tmp;
+ }
+ return shuffled;
+ }
+
+ function playlistTracks(playlistId, shuffle) {
+ var playlist = findPlaylistById(playlistId);
+ if (!playlist) {
+ return [];
+ }
+
+ var tracks = [];
+ var entryIds = playlist.entryIds || [];
+ for (var i = 0; i < entryIds.length; i++) {
+ var entryId = String(entryIds[i] || "");
+ for (var j = 0; j < libraryEntries.length; j++) {
+ if (String(libraryEntries[j].id || "") === entryId) {
+ tracks.push(copyEntry(libraryEntries[j]));
+ break;
+ }
+ }
+ }
+
+ return shuffle === true ? shuffleEntries(tracks) : tracks;
+ }
+
+ function createPlaylist(name, entryIdToAdd) {
+ var targetName = String(name || "").trim();
+ var targetEntryId = String(entryIdToAdd || "").trim();
+ if (targetName.length === 0 || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.pendingPlaylistCreateName = targetEntryId.length > 0 ? targetName : "";
+ root.pendingPlaylistCreateEntryId = targetEntryId;
+ root.playlistsBusy = true;
+ playlistsProcess.exec({
+ "command": buildCommand(["create-playlist", targetName])
+ });
+ }
+
+ function renamePlaylist(playlistId, name) {
+ var targetId = String(playlistId || "").trim();
+ var targetName = String(name || "").trim();
+ if (targetId.length === 0 || targetName.length === 0 || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.playlistsBusy = true;
+ playlistsProcess.exec({
+ "command": buildCommand(["rename-playlist", targetId, targetName])
+ });
+ }
+
+ function deletePlaylist(playlistId) {
+ var targetId = String(playlistId || "").trim();
+ if (targetId.length === 0 || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.playlistsBusy = true;
+ playlistsProcess.exec({
+ "command": buildCommand(["delete-playlist", targetId])
+ });
+ }
+
+ function addToPlaylist(playlistId, entryId) {
+ var pId = String(playlistId || "").trim();
+ var eId = String(entryId || "").trim();
+ if (pId.length === 0 || eId.length === 0 || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.playlistsBusy = true;
+ playlistsProcess.exec({
+ "command": buildCommand(["playlist-add", pId, eId])
+ });
+ }
+
+ function removeFromPlaylist(playlistId, entryId) {
+ var pId = String(playlistId || "").trim();
+ var eId = String(entryId || "").trim();
+ if (pId.length === 0 || eId.length === 0 || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.playlistsBusy = true;
+ playlistsProcess.exec({
+ "command": buildCommand(["playlist-remove", pId, eId])
+ });
+ }
+
+ function finishQueueBatch(errorText, noticeText) {
+ root.queueBatchBusy = false;
+ root.queueBusy = false;
+ root.queueBatchEntries = [];
+ root.queueBatchIndex = 0;
+ root.queueBatchFinalAction = "";
+ root.queueBatchDoneNotice = "";
+ root.queueControlAction = "";
+ root.queueCommandAction = "";
+
+ if (errorText && String(errorText).length > 0) {
+ root.lastNotice = "";
+ root.lastError = String(errorText);
+ return;
+ }
+
+ root.lastError = "";
+ if (noticeText && String(noticeText).length > 0) {
+ root.lastNotice = String(noticeText);
+ }
+ }
+
+ function runNextQueueBatchStep() {
+ if (!root.queueBatchBusy) {
+ return;
+ }
+
+ if (root.queueBatchIndex < root.queueBatchEntries.length) {
+ var entry = root.queueBatchEntries[root.queueBatchIndex];
+ queueBatchProcess.exec({
+ "command": buildQueueCommand("enqueue", [
+ entry.id || "",
+ entry.title || root.untitledLabel,
+ entry.url || "",
+ entry.uploader || "",
+ String(entry.duration || 0),
+ root.entrySavedState(entry)
+ ])
+ });
+ return;
+ }
+
+ if (root.queueBatchFinalAction.length > 0) {
+ if (root.queueBatchFinalAction === "skip") {
+ root.skipQueue();
+ } else if (root.queueBatchFinalAction === "start") {
+ root.startQueue();
+ } else if (root.queueBatchFinalAction === "stop") {
+ root.stopQueue();
+ }
+ root.finishQueueBatch("", root.queueBatchDoneNotice);
+ return;
+ }
+
+ root.finishQueueBatch("", root.queueBatchDoneNotice);
+ }
+
+ function startQueueBatch(entries, options) {
+ if (root.queueBusy || root.queueBatchBusy) {
+ return;
+ }
+
+ var filteredEntries = [];
+ for (var i = 0; i < entries.length; i++) {
+ var normalized = copyEntry(entries[i]);
+ if (normalized.url.length > 0) {
+ filteredEntries.push(normalized);
+ }
+ }
+
+ var clearFirst = options?.clearFirst === true;
+ var finalAction = String(options?.finalAction || "").trim();
+ var doneNotice = String(options?.doneNotice || "").trim();
+ var emptyNotice = String(options?.emptyNotice || root.playlistEmptyNotice).trim();
+
+ if (filteredEntries.length === 0) {
+ root.lastError = "";
+ root.lastNotice = emptyNotice;
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.queueBusy = true;
+ root.queueBatchBusy = true;
+ root.queueBatchEntries = filteredEntries;
+ root.queueBatchIndex = 0;
+ root.queueBatchFinalAction = finalAction;
+ root.queueBatchDoneNotice = doneNotice;
+
+ if (clearFirst) {
+ root.queueControlAction = "clear";
+ queueControlProcess.exec({
+ "command": buildQueueCommand("clear", [])
+ });
+ return;
+ }
+
+ root.runNextQueueBatchStep();
+ }
+
+ function queuePlaylist(playlistId, shuffle) {
+ var playlist = findPlaylistById(playlistId);
+ var entries = playlistTracks(playlistId, shuffle);
+ var playlistName = playlist?.name || root.pluginApi?.tr("playlists.untitled");
+ startQueueBatch(entries, {
+ "clearFirst": false,
+ "doneNotice": shuffle === true
+ ? (root.pluginApi?.tr("notices.addedShuffledPlaylistToQueue", {"name": playlistName}))
+ : (root.pluginApi?.tr("notices.addedPlaylistToQueue", {"name": playlistName})),
+ "emptyNotice": root.pluginApi?.tr("notices.playlistEmpty")
+ });
+ }
+
+ function playPlaylist(playlistId, shuffle) {
+ var playlist = findPlaylistById(playlistId);
+ var entries = playlistTracks(playlistId, shuffle);
+ if (entries.length === 0) {
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("notices.playlistEmpty");
+ return;
+ }
+
+ var playlistName = playlist?.name || root.pluginApi?.tr("playlists.untitled");
+ startQueueBatch(entries, {
+ "clearFirst": true,
+ "finalAction": "skip",
+ "doneNotice": shuffle === true
+ ? (root.pluginApi?.tr("notices.playingShuffledPlaylist", {"name": playlistName}))
+ : (root.pluginApi?.tr("notices.playingPlaylist", {"name": playlistName})),
+ "emptyNotice": root.pluginApi?.tr("notices.playlistEmpty")
+ });
+ }
+
+ function applyQueue(rawText) {
+ var text = String(rawText || "").trim();
+ if (text === root.lastQueueSnapshot) {
+ return;
+ }
+
+ root.lastQueueSnapshot = text;
+ if (text.length === 0) {
+ root.queueEntries = [];
+ return;
+ }
+
+ try {
+ var parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) {
+ root.queueEntries = parsed.map(function (entry) {
+ return {
+ "id": entry?.id ?? "",
+ "title": entry?.title || root.untitledLabel,
+ "url": entry?.url ?? "",
+ "uploader": entry?.uploader ?? "",
+ "duration": entry?.duration ?? 0,
+ "isSaved": typeof entry?.isSaved === "boolean" ? entry.isSaved : undefined,
+ "queuedAt": entry?.queuedAt ?? ""
+ };
+ });
+ } else {
+ root.queueEntries = [];
+ }
+ } catch (error) {
+ Logger.w("MusicSearch", "Failed to parse queue:", error);
+ root.queueEntries = [];
+ }
+ }
+
+ function removeQueueEntry(entryId, showNotice) {
+ var targetId = String(entryId || "").trim();
+ if (targetId.length === 0 || root.queueBusy || root.queueBatchBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ if (showNotice !== false) {
+ root.lastNotice = "";
+ }
+ root.queueBusy = true;
+ root.queueCommandAction = showNotice === false ? "remove-silent" : "remove";
+ queueProcess.exec({
+ "command": buildQueueCommand("remove", [targetId])
+ });
+ }
+
+ function clearQueue() {
+ if (root.queueBusy || root.queueBatchBusy) {
+ return;
+ }
+
+ root.queueActive = false;
+ root.pendingAutoplaySaved = false;
+ root.pendingAutoplaySavedShuffle = false;
+ root.pendingQueueLaunchEntry = null;
+ root.pendingQueueLaunchEntryId = "";
+ root.queueLaunchBusy = false;
+ root.lastError = "";
+ root.lastNotice = "";
+ root.queueBusy = true;
+ root.queueCommandAction = "clear";
+ queueProcess.exec({
+ "command": buildQueueCommand("clear", [])
+ });
+ }
+
+ function launchQueueEntry(entry) {
+ var normalized = copyEntry(entry);
+ if (!normalized.url || root.queueLaunchBusy || root.playbackBusy) {
+ return;
+ }
+
+ root.queueActive = true;
+ root.pendingQueueLaunchEntry = normalized;
+ root.pendingQueueLaunchEntryId = normalized.id;
+ root.queueLaunchBusy = true;
+ playEntry(normalized);
+ }
+
+ function playQueueEntryNow(entry) {
+ if (!entry || !entry.url) {
+ return;
+ }
+
+ launchQueueEntry(entry);
+ }
+
+ function startQueue() {
+ if (root.queueEntries.length === 0) {
+ root.queueActive = false;
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.loadedEmpty");
+ return;
+ }
+
+ root.queueActive = true;
+ if (!root.isPlaying && !root.playbackBusy) {
+ root.launchQueueEntry(root.queueEntries[0]);
+ } else {
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.armed");
+ }
+ }
+
+ function stopQueue() {
+ root.queueActive = false;
+ root.pendingAutoplaySaved = false;
+ root.pendingAutoplaySavedShuffle = false;
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.stopped");
+ }
+
+ function skipQueue() {
+ if (root.queueEntries.length === 0) {
+ root.queueActive = false;
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.noNext");
+ return;
+ }
+
+ root.launchQueueEntry(root.queueEntries[0]);
+ }
+
+ function autoplaySavedTracks(shuffle) {
+ if (root.queueBusy || root.queueBatchBusy) {
+ return;
+ }
+
+ root.queueActive = false;
+ root.pendingAutoplaySaved = shuffle !== true;
+ root.pendingAutoplaySavedShuffle = shuffle === true;
+ root.lastError = "";
+ root.lastNotice = shuffle === true ? root.pluginApi?.tr("queue.loadingShuffledSaved") : root.pluginApi?.tr("queue.loadingSaved");
+ root.queueBusy = true;
+ root.queueCommandAction = "load-library";
+ queueProcess.exec({
+ "command": buildQueueCommand("load-library", [root.libraryPath, shuffle === true ? "shuffle" : "ordered"])
+ });
+ }
+
+ function saveEntry(entry) {
+ if (!entry || !entry.url) {
+ return;
+ }
+
+ var entryUrl = trimmedText(entry.url);
+ var effectiveTitle = trimmedText(entry.title || entry.name);
+ var effectiveUploader = trimmedText(entry.uploader);
+ var effectiveDuration = entry.duration || 0;
+ var usingCurrentPlayback = entryUrl.length > 0 && entryUrl === root.currentUrl;
+
+ if (usingCurrentPlayback && !isPlaceholderEntryTitle(root.currentTitle)) {
+ effectiveTitle = trimmedText(root.currentTitle);
+ if (effectiveUploader.length === 0 && trimmedText(root.currentUploader).length > 0) {
+ effectiveUploader = trimmedText(root.currentUploader);
+ }
+ if ((!isFinite(effectiveDuration) || effectiveDuration <= 0) && (root.currentDuration || 0) > 0) {
+ effectiveDuration = root.currentDuration || 0;
+ }
+ }
+
+ if (!entryUrl.startsWith("/") && isPlaceholderEntryTitle(effectiveTitle)) {
+ root.saveUrl(entryUrl);
+ return;
+ }
+
+ if (effectiveTitle.length === 0 && usingCurrentPlayback) {
+ effectiveTitle = trimmedText(root.currentTitle);
+ if (effectiveUploader.length === 0) {
+ effectiveUploader = trimmedText(root.currentUploader);
+ }
+ if (!isFinite(effectiveDuration) || effectiveDuration <= 0) {
+ effectiveDuration = root.currentDuration || 0;
+ }
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand([
+ "save",
+ entry.id || "",
+ effectiveTitle || root.untitledLabel,
+ entryUrl,
+ effectiveUploader,
+ String(isFinite(effectiveDuration) ? effectiveDuration : 0)
+ ])
+ });
+ }
+
+ function saveUrl(url) {
+ var cleanedUrl = normalizeUrl(url);
+ if (cleanedUrl.length === 0) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["save-url", cleanedUrl])
+ });
+ }
+
+ function removeEntry(entryId) {
+ var targetId = String(entryId || "").trim();
+ if (targetId.length === 0) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["remove", targetId])
+ });
+ }
+
+ function editMetadata(entryId, field, value) {
+ var targetId = String(entryId || "").trim();
+ var targetField = String(field || "").trim();
+ var targetValue = String(value || "");
+ if (targetId.length === 0 || targetField.length === 0 || root.libraryBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.libraryBusy = true;
+ libraryProcess.exec({
+ "command": buildCommand(["edit-metadata", targetId, targetField, targetValue])
+ });
+ }
+
+ function importFolderAsPlaylist(folderPath, playlistName) {
+ var targetFolder = String(folderPath || "").trim();
+ var targetName = String(playlistName || "").trim();
+ if (targetFolder.length === 0 || root.importBusy || root.libraryBusy || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("notices.importingFolder");
+ root.importBusy = true;
+
+ var commandArgs = ["import-folder-playlist", targetFolder];
+ if (targetName.length > 0) {
+ commandArgs.push(targetName);
+ }
+
+ importProcess.exec({
+ "command": buildCommand(commandArgs)
+ });
+ }
+
+ function syncFolderPlaylist(playlistId) {
+ var targetId = String(playlistId || "").trim();
+ if (targetId.length === 0 || root.importBusy || root.playlistsBusy) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("notices.syncingFolder");
+ root.importBusy = true;
+ importProcess.exec({
+ "command": buildCommand(["sync-folder-playlist", targetId])
+ });
+ }
+
+ function downloadEntry(entry) {
+ if (!entry || !entry.url) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.downloadBusy = true;
+ downloadProcess.exec({
+ "command": buildCommand([
+ "download-mp3",
+ entry.title || root.downloadedTrackLabel,
+ entry.url || ""
+ ])
+ });
+ }
+
+ function downloadCurrentTrack() {
+ var entry = currentTrackEntry();
+ if (!entry.url || isLocalEntry(entry)) {
+ return;
+ }
+
+ downloadEntry(entry);
+ }
+
+ function maybeAutoDownloadCurrentTrack() {
+ var entry = currentTrackEntry();
+ if (!entry.url || root.downloadBusy || isLocalEntry(entry)) {
+ return;
+ }
+
+ root.lastNotice = root.pluginApi?.tr("notices.savingMp3");
+ downloadEntry(entry);
+ }
+
+ function downloadUrl(url, label) {
+ var cleanedUrl = normalizeUrl(url);
+ if (cleanedUrl.length === 0) {
+ return;
+ }
+
+ downloadEntry({
+ "title": label || root.downloadedTrackLabel,
+ "url": cleanedUrl
+ });
+ }
+
+ function enqueueEntry(entry) {
+ if (!entry || !entry.url) {
+ return;
+ }
+
+ root.lastError = "";
+ root.lastNotice = "";
+ root.queueBusy = true;
+ root.queueCommandAction = "enqueue";
+ queueProcess.exec({
+ "command": buildQueueCommand("enqueue", [
+ entry.id || "",
+ entry.title || entry.name || root.untitledLabel,
+ entry.url || "",
+ entry.uploader || "",
+ String(entry.duration || 0),
+ root.entrySavedState(entry)
+ ])
+ });
+ }
+
+ function enqueueUrl(url, label) {
+ var cleanedUrl = normalizeUrl(url);
+ if (cleanedUrl.length === 0) {
+ return;
+ }
+
+ enqueueEntry({
+ "id": "queued-" + Date.now(),
+ "title": label || root.queuedUrlLabel,
+ "url": cleanedUrl,
+ "uploader": "",
+ "duration": 0
+ });
+ }
+
+ function isSaved(entry) {
+ if (!entry) {
+ return false;
+ }
+
+ for (var i = 0; i < libraryEntries.length; i++) {
+ var candidate = libraryEntries[i];
+ if (candidate.isSaved === false) {
+ continue;
+ }
+ if (entry.id && candidate.id === entry.id) {
+ return true;
+ }
+ if (entry.url && candidate.url === entry.url) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function findSavedEntry(entry) {
+ if (!entry) {
+ return null;
+ }
+
+ for (var i = 0; i < libraryEntries.length; i++) {
+ var candidate = libraryEntries[i];
+ if (candidate.isSaved === false) {
+ continue;
+ }
+ if (entry.id && candidate.id === entry.id) {
+ return candidate;
+ }
+ if (entry.url && candidate.url === entry.url) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ function findLibraryEntry(entry) {
+ if (!entry) {
+ return null;
+ }
+
+ for (var i = 0; i < libraryEntries.length; i++) {
+ var candidate = libraryEntries[i];
+ if (entry.id && candidate.id === entry.id) {
+ return candidate;
+ }
+ if (entry.url && candidate.url === entry.url) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ function syncCurrentPlaybackMetadataFromLibrary() {
+ var activeEntry = root.findLibraryEntry({
+ "id": root.currentEntryId,
+ "url": root.currentUrl
+ });
+ if (!activeEntry) {
+ return;
+ }
+
+ var libraryTitle = String(activeEntry.title || "").trim();
+ var libraryUploader = String(activeEntry.uploader || "").trim();
+ var libraryDuration = Number(activeEntry.duration || 0);
+
+ if (libraryTitle.length > 0) {
+ root.currentTitle = libraryTitle;
+ }
+
+ if (libraryUploader.length > 0 || (root.currentUploader || "").trim().length === 0) {
+ root.currentUploader = libraryUploader;
+ }
+
+ if (isFinite(libraryDuration) && libraryDuration > 0) {
+ root.currentDuration = libraryDuration;
+ }
+ }
+
+ function refreshStatus(forceRefresh) {
+ if ((root.statusBusy || root.playbackBusy) && forceRefresh !== true) {
+ return;
+ }
+
+ root.statusBusy = true;
+ statusProcess.exec({
+ "command": buildCommand(["status"])
+ });
+ }
+
+ function openLauncher() {
+ if (!pluginApi) {
+ return;
+ }
+
+ pluginApi.withCurrentScreen(function (screen) {
+ pluginApi.openLauncher(screen);
+ });
+ }
+
+ function openPanel() {
+ if (!pluginApi) {
+ return;
+ }
+
+ pluginApi.withCurrentScreen(function (screen) {
+ pluginApi.openPanel(screen);
+ });
+ }
+
+ function togglePanelView() {
+ if (!pluginApi) {
+ return;
+ }
+
+ pluginApi.withCurrentScreen(function (screen) {
+ pluginApi.togglePanel(screen);
+ });
+ }
+
+ function applyState(rawText) {
+ var text = String(rawText || "").trim();
+ if (text.length === 0) {
+ return;
+ }
+
+ try {
+ var state = JSON.parse(text);
+ var previousEntryId = root.currentEntryId;
+ var previousUrl = root.currentUrl;
+ root.isPlaying = state.isPlaying === true;
+ root.isPaused = state.isPaused === true;
+ root.currentEntryId = state.id || "";
+ root.currentTitle = state.title || "";
+ root.currentUrl = state.url || "";
+ root.currentUploader = state.uploader || "";
+ root.currentDuration = state.duration || 0;
+ root.currentSpeed = state.speed || 1;
+ root.currentPid = state.pid || 0;
+ root.lastError = state.error || "";
+ root.currentEndReason = state.endReason || "";
+ root.currentUpdatedAt = state.updatedAt || "0";
+ root.syncCurrentPlaybackMetadataFromLibrary();
+ if ((root.currentEntryId && root.currentEntryId === previousEntryId) || (root.currentUrl && root.currentUrl === previousUrl) || state.isPlaying !== true) {
+ root.playbackStarting = false;
+ root.playbackStartingProvider = "";
+ root.playbackStartingMessage = "";
+ }
+ if (!state.isPlaying) {
+ root.currentPosition = 0;
+ root.currentSpeed = state.speed || 1;
+ } else if (root.currentEntryId !== previousEntryId || root.currentUrl !== previousUrl) {
+ root.currentPosition = 0;
+ }
+
+ if (root.pendingQueueLaunchEntry
+ && root.isPlaying
+ && ((root.pendingQueueLaunchEntry.id && root.currentEntryId === root.pendingQueueLaunchEntry.id)
+ || (root.pendingQueueLaunchEntry.url && root.currentUrl === root.pendingQueueLaunchEntry.url))) {
+ root.queueLaunchBusy = false;
+ root.removeQueueEntry(root.pendingQueueLaunchEntry.id, false);
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.nowPlaying", {"title": root.pendingQueueLaunchEntry.title || root.pluginApi?.tr("common.untitled")});
+ root.pendingQueueLaunchEntry = null;
+ root.pendingQueueLaunchEntryId = "";
+ }
+
+ if (root.queueActive
+ && !root.isPlaying
+ && root.currentUpdatedAt.length > 0
+ && root.currentUpdatedAt !== root.lastHandledQueueUpdate
+ && (root.currentEndReason === "finished" || root.currentEndReason === "error")) {
+ root.lastHandledQueueUpdate = root.currentUpdatedAt;
+ root.queueLaunchBusy = false;
+ if (root.queueEntries.length > 0) {
+ root.launchQueueEntry(root.queueEntries[0]);
+ } else {
+ root.queueActive = false;
+ if (root.currentEndReason === "error") {
+ root.lastNotice = "";
+ root.lastError = root.pluginApi?.tr("queue.errorExhausted");
+ } else {
+ root.lastError = "";
+ root.lastNotice = root.pluginApi?.tr("queue.finished");
+ }
+ }
+ }
+ } catch (error) {
+ Logger.w("MusicSearch", "Failed to parse music state:", error);
+ }
+ }
+
+ function applyLibrary(rawText) {
+ var text = String(rawText || "").trim();
+ if (text.length === 0) {
+ root.libraryEntries = [];
+ return;
+ }
+
+ try {
+ var parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) {
+ root.libraryEntries = parsed.map(function (entry) {
+ return {
+ "id": entry?.id ?? "",
+ "title": entry?.title || root.untitledLabel,
+ "url": entry?.url ?? "",
+ "uploader": entry?.uploader ?? "",
+ "duration": entry?.duration ?? 0,
+ "savedAt": entry?.savedAt ?? "",
+ "provider": entry?.provider ?? "",
+ "album": entry?.album ?? "",
+ "localPath": entry?.localPath ?? "",
+ "isSaved": entry?.isSaved !== false,
+ "tags": Array.isArray(entry?.tags) ? entry.tags : [],
+ "rating": entry?.rating ?? 0,
+ "playCount": entry?.playCount ?? 0,
+ "lastPlayedAt": entry?.lastPlayedAt ?? ""
+ };
+ });
+ root.syncCurrentPlaybackMetadataFromLibrary();
+ } else {
+ root.libraryEntries = [];
+ }
+ } catch (error) {
+ Logger.w("MusicSearch", "Failed to parse music library:", error);
+ root.libraryEntries = [];
+ }
+ }
+
+ function visibleLibraryEntries() {
+ return libraryEntries.filter(function (entry) {
+ return entry?.isSaved !== false;
+ });
+ }
+
+ function applySettings(rawText) {
+ var text = String(rawText || "").trim();
+ if (text.length === 0) {
+ return;
+ }
+
+ try {
+ var settings = JSON.parse(text);
+ root.currentProvider = settings.activeProvider || "youtube";
+ root.currentSortBy = settings.sortBy || "date";
+ root.downloadDirectory = settings.downloadDirectory || (Quickshell.env("HOME") + "/Music/Noctalia");
+ root.downloadCacheMaxMb = settings.downloadCacheMaxMb || 0;
+ root.ytPlayerClient = settings.ytPlayerClient || "android";
+ } catch (error) {
+ Logger.w("MusicSearch", "Failed to parse music settings:", error);
+ }
+ }
+
+ function applyPlaylists(rawText) {
+ var text = String(rawText || "").trim();
+ if (text.length === 0) {
+ root.playlistEntries = [];
+ return;
+ }
+
+ try {
+ var parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) {
+ root.playlistEntries = parsed;
+ } else {
+ root.playlistEntries = [];
+ }
+ } catch (error) {
+ Logger.w("MusicSearch", "Failed to parse playlists:", error);
+ root.playlistEntries = [];
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/MusicPreview.qml b/.config/noctalia/plugins/music-search/MusicPreview.qml
new file mode 100644
index 0000000..c153ae9
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/MusicPreview.qml
@@ -0,0 +1,893 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Widgets
+import "MusicUtils.js" as MusicUtils
+
+Item {
+ id: previewPanel
+
+ property var currentItem: null
+ property var detailData: null
+ property bool loadingDetails: false
+ property string lastLoadedUrl: ""
+ property string requestedUrl: ""
+ property string previewError: ""
+ property bool descriptionExpanded: false
+ property bool showChips: true
+ property bool showLengthDetails: true
+ property bool showPlaybackProgress: true
+ property bool showInlineSpeedControls: true
+ property real livePosition: 0
+ property bool seekDragging: false
+ readonly property var detailCacheOwner: currentItem?.provider || null
+ readonly property string previewMetadataMode: detailCacheOwner?.mainInstance?.previewMetadataMode ?? detailCacheOwner?.pluginApi?.pluginSettings?.previewMetadataMode ?? currentItem?.previewMetadataMode ?? detailCacheOwner?.pluginApi?.manifest?.metadata?.defaultSettings?.previewMetadataMode ?? "always"
+ readonly property string previewThumbnailSize: detailCacheOwner?.mainInstance?.previewThumbnailSize ?? detailCacheOwner?.pluginApi?.pluginSettings?.previewThumbnailSize ?? currentItem?.previewThumbnailSize ?? detailCacheOwner?.pluginApi?.manifest?.metadata?.defaultSettings?.previewThumbnailSize ?? "comfortable"
+ readonly property real compactArtworkHeight: Math.round((previewThumbnailSize === "small" ? 120 : (previewThumbnailSize === "large" ? 190 : 150)) * Style.uiScaleRatio)
+ readonly property real fullArtworkHeight: Math.round((previewThumbnailSize === "small" ? 180 : (previewThumbnailSize === "large" ? 280 : 220)) * Style.uiScaleRatio)
+ readonly property real effectiveArtworkHeight: {
+ if (contentArea.height <= 0) {
+ return fullArtworkHeight;
+ }
+ var responsiveHeight = Math.round(contentArea.height * 0.4);
+ return Math.max(compactArtworkHeight, Math.min(fullArtworkHeight, responsiveHeight));
+ }
+ readonly property bool compactLayout: contentArea.height > 0 && effectiveArtworkHeight < (fullArtworkHeight - Math.round(8 * Style.uiScaleRatio))
+ readonly property bool showUploaderMetadata: detailCacheOwner?.mainInstance?.showUploaderMetadata !== false
+ readonly property bool showAlbumMetadata: detailCacheOwner?.mainInstance?.showAlbumMetadata !== false
+ readonly property bool showDurationMetadata: detailCacheOwner?.mainInstance?.showDurationMetadata !== false
+ readonly property bool showPlayStatsMetadata: detailCacheOwner?.mainInstance?.showPlayStatsMetadata !== false
+ readonly property bool showStatusMetadata: detailCacheOwner?.mainInstance?.showStatusMetadata !== false
+ readonly property real currentSpeed: detailCacheOwner?.mainInstance?.currentSpeed || 1
+ readonly property real maxPreviewHeight: Math.round(560 * Style.uiScaleRatio)
+
+ implicitHeight: Math.min(previewContent.implicitHeight + (Style.marginS * 2), maxPreviewHeight)
+
+ function previewTr(key, params) {
+ return detailCacheOwner?.pluginApi?.tr(key, params) ?? "";
+ }
+
+ function detailCache() {
+ return detailCacheOwner?.previewDetailCache || ({});
+ }
+
+ function cacheDetailsForUrl(url, details) {
+ if (!detailCacheOwner || !url) {
+ return;
+ }
+
+ var updatedCache = {};
+ var existingCache = detailCache();
+ for (var key in existingCache) {
+ updatedCache[key] = existingCache[key];
+ }
+ updatedCache[url] = details;
+
+ var cacheKeys = Object.keys(updatedCache);
+ if (cacheKeys.length > 50) {
+ delete updatedCache[cacheKeys[0]];
+ }
+
+ detailCacheOwner.previewDetailCache = updatedCache;
+ }
+
+ function resetPreviewState(clearCache) {
+ detailDelay.stop();
+ detailData = null;
+ previewError = "";
+ loadingDetails = false;
+ requestedUrl = "";
+ lastLoadedUrl = "";
+ descriptionExpanded = false;
+ livePosition = 0;
+ seekDragging = false;
+ positionSyncTimer.stop();
+ }
+
+ function isItemPlayingNow(item) {
+ var targetItem = item || currentItem;
+ if (!targetItem) {
+ return false;
+ }
+
+ var mainInstance = detailCacheOwner?.mainInstance || null;
+ if (!mainInstance || mainInstance.isPlaying !== true) {
+ return targetItem?.isPlaying === true;
+ }
+
+ var itemId = (targetItem.id || "").trim();
+ if (itemId.length > 0 && mainInstance.currentEntryId === itemId) {
+ return true;
+ }
+
+ var itemUrl = (targetItem.url || "").trim();
+ return itemUrl.length > 0 && mainInstance.currentUrl === itemUrl;
+ }
+
+ function isItemStartingNow(item) {
+ var targetItem = item || currentItem;
+ if (!targetItem) {
+ return false;
+ }
+
+ var mainInstance = detailCacheOwner?.mainInstance || null;
+ if (!mainInstance || mainInstance.playbackStarting !== true) {
+ return targetItem?.isStarting === true;
+ }
+
+ var itemId = (targetItem.id || "").trim();
+ if (itemId.length > 0 && mainInstance.currentEntryId === itemId) {
+ return true;
+ }
+
+ var itemUrl = (targetItem.url || "").trim();
+ return itemUrl.length > 0 && mainInstance.currentUrl === itemUrl;
+ }
+
+ function startupMessage() {
+ var mainInstance = detailCacheOwner?.mainInstance || null;
+ if (!isItemStartingNow(currentItem)) {
+ return "";
+ }
+ return mainInstance?.playbackStartingMessage || previewTr("status.startingPlayback");
+ }
+
+ function richMetadataAllowedForItem(item) {
+ var targetItem = item || currentItem;
+ if (!targetItem || !targetItem.url || !targetItem.helperPath) {
+ return false;
+ }
+
+ if (previewMetadataMode === "never") {
+ return false;
+ }
+
+ if (previewMetadataMode === "playing") {
+ return isItemPlayingNow(targetItem);
+ }
+
+ return true;
+ }
+
+ function syncRichMetadataState() {
+ detailDelay.stop();
+ loadingDetails = false;
+ requestedUrl = "";
+ previewError = "";
+
+ var nextUrl = currentItem?.url || "";
+ if (!nextUrl || !richMetadataAllowedForItem(currentItem)) {
+ detailData = null;
+ lastLoadedUrl = "";
+ return;
+ }
+
+ var cached = detailCache()[nextUrl];
+ detailData = cached || null;
+ lastLoadedUrl = cached ? nextUrl : "";
+ if (!cached) {
+ detailDelay.restart();
+ }
+ }
+
+ function formatViews(count) {
+ var total = count || 0;
+ if (!isFinite(total) || total <= 0) {
+ return "";
+ }
+ if (total >= 1000000000) {
+ return previewTr("preview.viewCountB", {"count": (total / 1000000000).toFixed(1).replace(/\.0$/, "")});
+ }
+ if (total >= 1000000) {
+ return previewTr("preview.viewCountM", {"count": (total / 1000000).toFixed(1).replace(/\.0$/, "")});
+ }
+ if (total >= 1000) {
+ return previewTr("preview.viewCountK", {"count": (total / 1000).toFixed(1).replace(/\.0$/, "")});
+ }
+ return previewTr("preview.viewCount", {"count": total});
+ }
+
+ function formatUploadDate(rawDate) {
+ var text = (rawDate || "").trim();
+ if (text.length !== 8) {
+ return "";
+ }
+ return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8);
+ }
+
+ function formatSpeed(value) {
+ var speed = value ?? 1;
+ if (!isFinite(speed) || speed <= 0) {
+ speed = 1;
+ }
+ return previewTr("speed.multiplier", {"speed": speed.toFixed(2)});
+ }
+
+ function effectiveTitle() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return visibleDetailData?.title || currentItem?.name || currentItem?.title || previewTr("common.untitled");
+ }
+
+ function effectiveUploader() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return visibleDetailData?.uploader || visibleDetailData?.channel || currentItem?.uploader || "";
+ }
+
+ function effectiveAlbum() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return visibleDetailData?.album || currentItem?.album || "";
+ }
+
+ function effectiveDuration() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return visibleDetailData?.duration || currentItem?.duration || 0;
+ }
+
+ function fullDescription() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return (visibleDetailData?.description || "").trim();
+ }
+
+ function descriptionNeedsExpansion() {
+ return fullDescription().length > 500;
+ }
+
+ function effectiveDescription() {
+ var description = fullDescription();
+ if (!descriptionExpanded && description.length > 500) {
+ return description.slice(0, 500) + "...";
+ }
+ return description;
+ }
+
+ function effectiveThumbnail() {
+ var visibleDetailData = richMetadataAllowedForItem(currentItem) ? detailData : null;
+ return visibleDetailData?.thumbnail || "";
+ }
+
+ function syncLivePosition() {
+ if (seekDragging) {
+ return;
+ }
+
+ var mainInstance = detailCacheOwner?.mainInstance || null;
+ if (isItemPlayingNow(currentItem) && mainInstance) {
+ var nextPosition = mainInstance.currentPosition ?? 0;
+ livePosition = isFinite(nextPosition) && nextPosition >= 0 ? nextPosition : 0;
+ return;
+ }
+
+ if (!isItemPlayingNow(currentItem)) {
+ livePosition = 0;
+ }
+ }
+
+ function requestSeekForRatio(ratio) {
+ var duration = effectiveDuration() || 0;
+ var nextRatio = ratio;
+ if (!isFinite(duration) || duration <= 0 || !isFinite(nextRatio)) {
+ return;
+ }
+
+ nextRatio = Math.max(0, Math.min(1, nextRatio));
+ livePosition = nextRatio * duration;
+ detailCacheOwner?.mainInstance?.seekToRatio(nextRatio);
+ }
+
+ function loadDetails() {
+ if (!richMetadataAllowedForItem(currentItem)) {
+ detailData = null;
+ previewError = "";
+ loadingDetails = false;
+ requestedUrl = "";
+ lastLoadedUrl = "";
+ return;
+ }
+
+ if (!currentItem || !currentItem.url || !currentItem.helperPath) {
+ return;
+ }
+
+ var cached = detailCache()[currentItem.url];
+ if (cached) {
+ detailData = cached;
+ lastLoadedUrl = currentItem.url;
+ previewError = "";
+ return;
+ }
+
+ if (lastLoadedUrl === currentItem.url && detailData) {
+ return;
+ }
+
+ loadingDetails = true;
+ previewError = "";
+ requestedUrl = currentItem.url;
+ detailsProcess.exec({
+ "command": ["bash", currentItem.helperPath, "details", currentItem.url]
+ });
+ }
+
+ Connections {
+ target: previewPanel
+ function onCurrentItemChanged() {
+ previewPanel.descriptionExpanded = false;
+ var nextUrl = previewPanel.currentItem?.url || "";
+ if (!nextUrl) {
+ previewPanel.resetPreviewState(true);
+ return;
+ }
+ previewPanel.syncRichMetadataState();
+ previewPanel.syncLivePosition();
+ }
+ }
+
+ onPreviewMetadataModeChanged: syncRichMetadataState()
+
+ Connections {
+ target: detailCacheOwner?.mainInstance || null
+
+ function onIsPlayingChanged() {
+ previewPanel.syncRichMetadataState();
+ previewPanel.syncLivePosition();
+ }
+
+ function onPreviewMetadataModeChanged() {
+ previewPanel.syncRichMetadataState();
+ }
+
+ function onCurrentEntryIdChanged() {
+ previewPanel.syncRichMetadataState();
+ previewPanel.syncLivePosition();
+ }
+
+ function onCurrentUrlChanged() {
+ previewPanel.syncRichMetadataState();
+ previewPanel.syncLivePosition();
+ }
+
+ function onCurrentPositionChanged() {
+ previewPanel.syncLivePosition();
+ }
+ }
+
+ Component.onDestruction: resetPreviewState(true)
+
+ Timer {
+ id: detailDelay
+ interval: currentItem?.previewDelayMs || 500
+ repeat: false
+ onTriggered: previewPanel.loadDetails()
+ }
+
+ Timer {
+ id: positionSyncTimer
+ interval: 250
+ repeat: true
+ running: isItemPlayingNow(currentItem) && !previewPanel.seekDragging
+
+ onTriggered: previewPanel.syncLivePosition()
+ }
+
+ Process {
+ id: detailsProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ loadingDetails = false;
+
+ if (!previewPanel.currentItem || requestedUrl !== previewPanel.currentItem.url || !previewPanel.richMetadataAllowedForItem(previewPanel.currentItem)) {
+ return;
+ }
+
+ if (exitCode === 0) {
+ try {
+ detailData = JSON.parse(String(detailsProcess.stdout.text || "{}"));
+ if (previewPanel.currentItem?.url) {
+ previewPanel.cacheDetailsForUrl(previewPanel.currentItem.url, detailData);
+ }
+ lastLoadedUrl = previewPanel.currentItem.url || "";
+ previewError = "";
+ } catch (error) {
+ detailData = null;
+ previewError = previewTr("preview.metadataParseError");
+ }
+ } else {
+ var fallback = previewPanel.currentItem?.url ? previewPanel.detailCache()[previewPanel.currentItem.url] : null;
+ detailData = fallback || null;
+ previewError = fallback ? "" : (String(detailsProcess.stderr.text || "").trim() || previewTr("preview.metadataUnavailable"));
+ }
+ }
+ }
+
+ NScrollView {
+ id: contentArea
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ clip: true
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurfaceVariant
+
+ Item {
+ id: previewContent
+ width: contentArea.availableWidth
+ implicitHeight: previewLayout.implicitHeight + (Style.marginS * 2)
+
+ ColumnLayout {
+ id: previewLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Style.marginS
+ spacing: Style.marginS
+
+ NImageRounded {
+ Layout.fillWidth: true
+ Layout.preferredHeight: effectiveArtworkHeight
+ radius: Style.radiusM
+ visible: imagePath !== ""
+ imagePath: effectiveThumbnail()
+ imageFillMode: Image.PreserveAspectCrop
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ visible: previewPanel.showChips && detailCacheOwner?.mainInstance?.showPreviewChips !== false
+ spacing: Style.marginXS
+
+ Repeater {
+ model: {
+ var chips = [];
+ if (currentItem?.sourceLabel) {
+ chips.push({
+ "label": currentItem.sourceLabel,
+ "clickable": false,
+ "query": ""
+ });
+ }
+ if (currentItem?.isSaved) {
+ chips.push({
+ "label": previewTr("preview.chipSaved"),
+ "clickable": true,
+ "query": (detailCacheOwner?.commandName || ">music-search") + " saved:"
+ });
+ }
+ if (isItemStartingNow(currentItem)) {
+ chips.push({
+ "label": previewTr("preview.chipStarting"),
+ "clickable": false,
+ "query": ""
+ });
+ }
+ var tags = currentItem?.tags || [];
+ for (var i = 0; i < tags.length; i++) {
+ var tag = (tags[i] || "").trim();
+ if (tag.length > 0) {
+ chips.push({
+ "label": "#" + tag,
+ "clickable": true,
+ "query": (detailCacheOwner?.commandName || ">music-search") + " #" + tag
+ });
+ }
+ }
+ return chips;
+ }
+
+ Rectangle {
+ radius: Style.radiusM
+ color: Color.mSecondary
+ implicitHeight: chipLabel.implicitHeight + Style.marginXS
+ implicitWidth: chipLabel.implicitWidth + Style.marginM
+
+ NText {
+ id: chipLabel
+ anchors.centerIn: parent
+ text: modelData?.label || ""
+ pointSize: Style.fontSizeXS
+ color: Color.mOnSecondary
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: modelData?.clickable === true
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (!enabled) {
+ return;
+ }
+ if (detailCacheOwner?.launcher && modelData?.query) {
+ detailCacheOwner.launcher.setSearchText(modelData.query);
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: effectiveTitle()
+ wrapMode: Text.Wrap
+ maximumLineCount: compactLayout ? 2 : 3
+ elide: Text.ElideRight
+ font.weight: Style.fontWeightBold
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurface
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ visible: (showUploaderMetadata && effectiveUploader() !== "") || isItemPlayingNow(currentItem)
+ spacing: Style.marginXS
+
+ NText {
+ Layout.fillWidth: true
+ visible: showUploaderMetadata && text !== ""
+ text: effectiveUploader()
+ pointSize: Style.fontSizeM
+ color: Color.mOnSurfaceVariant
+ wrapMode: compactLayout ? Text.NoWrap : Text.Wrap
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ visible: isItemPlayingNow(currentItem) && previewPanel.showInlineSpeedControls
+ spacing: Math.max(2, Math.round(Style.marginXS * 0.5))
+
+ NButton {
+ text: "-"
+ backgroundColor: "transparent"
+ textColor: Color.mOnSurfaceVariant
+ outlined: false
+ enabled: isItemPlayingNow(currentItem)
+ implicitWidth: Math.round(24 * Style.uiScaleRatio)
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ onClicked: detailCacheOwner?.mainInstance?.adjustSpeed(-0.05)
+ }
+
+ Rectangle {
+ radius: Style.radiusM
+ color: Color.mPrimary
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ implicitWidth: speedChipLabel.implicitWidth + Math.round(18 * Style.uiScaleRatio)
+
+ NText {
+ id: speedChipLabel
+ anchors.centerIn: parent
+ text: formatSpeed(currentSpeed)
+ pointSize: Style.fontSizeS
+ color: Color.mOnPrimary
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: detailCacheOwner?.mainInstance?.setSpeed(1.0)
+ }
+ }
+
+ NButton {
+ text: "+"
+ backgroundColor: "transparent"
+ textColor: Color.mOnSurfaceVariant
+ outlined: false
+ enabled: isItemPlayingNow(currentItem)
+ implicitWidth: Math.round(24 * Style.uiScaleRatio)
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ onClicked: detailCacheOwner?.mainInstance?.adjustSpeed(0.05)
+ }
+ }
+ }
+
+ Item {
+ id: progressWrapper
+ Layout.fillWidth: true
+ Layout.preferredHeight: progressSlider.implicitHeight + progressTimes.implicitHeight + Style.marginXS
+ visible: previewPanel.showPlaybackProgress && isItemPlayingNow(currentItem) && effectiveDuration() > 0
+ property real localSeekRatio: -1
+ property real lastSentSeekRatio: -1
+ property real seekEpsilon: 0.01
+ property real progressRatio: {
+ var duration = effectiveDuration() || 0;
+ if (!isFinite(duration) || duration <= 0) {
+ return 0;
+ }
+ var activePosition = previewPanel.seekDragging && localSeekRatio >= 0
+ ? (localSeekRatio * duration)
+ : (previewPanel.livePosition || 0);
+ var ratio = activePosition / duration;
+ if (!isFinite(ratio)) {
+ return 0;
+ }
+ return Math.max(0, Math.min(1, ratio));
+ }
+
+ Timer {
+ id: seekDebounce
+ interval: 75
+ repeat: false
+
+ onTriggered: {
+ if (previewPanel.seekDragging && progressWrapper.localSeekRatio >= 0) {
+ var next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio));
+ if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) {
+ previewPanel.requestSeekForRatio(next);
+ progressWrapper.lastSentSeekRatio = next;
+ }
+ }
+ }
+ }
+
+ NSlider {
+ id: progressSlider
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ from: 0
+ to: 1
+ stepSize: 0
+ snapAlways: false
+ heightRatio: 0.4
+ enabled: isItemPlayingNow(currentItem) && effectiveDuration() > 0
+ value: progressWrapper.progressRatio
+
+ onMoved: {
+ progressWrapper.localSeekRatio = value;
+ previewPanel.livePosition = value * (previewPanel.effectiveDuration() || 0);
+ seekDebounce.restart();
+ }
+ onPressedChanged: {
+ if (pressed) {
+ previewPanel.seekDragging = true;
+ progressWrapper.localSeekRatio = value;
+ previewPanel.requestSeekForRatio(value);
+ progressWrapper.lastSentSeekRatio = value;
+ } else {
+ seekDebounce.stop();
+ previewPanel.requestSeekForRatio(value);
+ previewPanel.seekDragging = false;
+ progressWrapper.localSeekRatio = -1;
+ progressWrapper.lastSentSeekRatio = -1;
+ previewPanel.syncLivePosition();
+ }
+ }
+ }
+
+ RowLayout {
+ id: progressTimes
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: progressSlider.bottom
+ anchors.topMargin: 2
+
+ NText {
+ text: MusicUtils.formatDuration(previewPanel.livePosition)
+ pointSize: Style.fontSizeXS
+ color: Color.mOnSurfaceVariant
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: MusicUtils.formatDuration(effectiveDuration())
+ pointSize: Style.fontSizeXS
+ color: Color.mOnSurfaceVariant
+ }
+ }
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ GridLayout {
+ Layout.fillWidth: true
+ columns: 2
+ rowSpacing: Style.marginXS
+ columnSpacing: Style.marginS
+
+ NText {
+ visible: showDurationMetadata && previewPanel.showLengthDetails
+ text: visible ? previewTr("preview.length") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: showDurationMetadata && previewPanel.showLengthDetails
+ text: MusicUtils.formatDuration(effectiveDuration()) || "-"
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: showAlbumMetadata && effectiveAlbum() !== ""
+ text: visible ? previewTr("preview.album") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: showAlbumMetadata && effectiveAlbum() !== ""
+ text: effectiveAlbum()
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: showPlayStatsMetadata && (currentItem?.playCount || 0) > 0
+ text: visible ? previewTr("preview.plays") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: showPlayStatsMetadata && (currentItem?.playCount || 0) > 0
+ text: currentItem?.playCount || 0
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: showPlayStatsMetadata && MusicUtils.formatRelativeTime(currentItem?.lastPlayedAt) !== ""
+ text: visible ? previewTr("preview.lastPlayed") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: showPlayStatsMetadata && MusicUtils.formatRelativeTime(currentItem?.lastPlayedAt) !== ""
+ text: MusicUtils.formatRelativeTime(currentItem?.lastPlayedAt)
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && formatUploadDate(detailData?.uploadDate) !== ""
+ text: visible ? previewTr("preview.uploaded") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && formatUploadDate(detailData?.uploadDate) !== ""
+ text: formatUploadDate(detailData?.uploadDate)
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && formatViews(detailData?.viewCount) !== ""
+ text: visible ? previewTr("preview.reach") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && formatViews(detailData?.viewCount) !== ""
+ text: formatViews(detailData?.viewCount)
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: showStatusMetadata && previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && !!detailData?.availability
+ text: visible ? previewTr("preview.status") : ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ NText {
+ visible: showStatusMetadata && previewPanel.richMetadataAllowedForItem(previewPanel.currentItem) && !!detailData?.availability
+ text: detailData?.availability || ""
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurface
+ horizontalAlignment: Text.AlignRight
+ Layout.fillWidth: true
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ visible: effectiveDescription() !== ""
+ || previewError !== ""
+ || String(currentItem?.lastError || "").trim().length > 0
+ implicitHeight: descriptionColumn.implicitHeight
+
+ ColumnLayout {
+ id: descriptionColumn
+ anchors.fill: parent
+ spacing: Math.max(4, Math.round(Style.marginXS * 0.75))
+
+ NText {
+ id: descriptionText
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ text: {
+ if (previewError)
+ return previewError;
+ if (currentItem?.lastError)
+ return previewTr("preview.lastPlaybackError", {"error": currentItem.lastError});
+ return effectiveDescription();
+ }
+ pointSize: Style.fontSizeS
+ color: (previewError || currentItem?.lastError) ? Color.mError : Color.mOnSurfaceVariant
+ }
+
+ NButton {
+ visible: !previewError && !currentItem?.lastError && descriptionNeedsExpansion()
+ text: previewPanel.descriptionExpanded ? previewTr("preview.readLess") : previewTr("preview.readMore")
+ backgroundColor: "transparent"
+ outlined: false
+ textColor: Color.mPrimary
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ implicitWidth: readMoreMeasure.implicitWidth + Math.round(16 * Style.uiScaleRatio)
+ onClicked: previewPanel.descriptionExpanded = !previewPanel.descriptionExpanded
+
+ NText {
+ id: readMoreMeasure
+ visible: false
+ text: parent.text
+ pointSize: Style.fontSizeS
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ visible: isItemStartingNow(currentItem)
+ spacing: Style.marginXS
+
+ NBusyIndicator {
+ running: true
+ size: Math.round(Style.baseWidgetSize * 0.75)
+ color: Color.mPrimary
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: startupMessage()
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.Wrap
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ visible: loadingDetails
+
+ NBusyIndicator {
+ running: true
+ size: Math.round(Style.baseWidgetSize * 0.75)
+ color: Color.mPrimary
+ }
+
+ NText {
+ text: previewTr("preview.loadingMetadata")
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+ }
+ }
+ }
+}
+}
diff --git a/.config/noctalia/plugins/music-search/MusicUtils.js b/.config/noctalia/plugins/music-search/MusicUtils.js
new file mode 100644
index 0000000..2260c44
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/MusicUtils.js
@@ -0,0 +1,47 @@
+function formatDuration(seconds) {
+ var total = Number(seconds || 0);
+ if (!isFinite(total) || total <= 0) {
+ return "";
+ }
+
+ var hrs = Math.floor(total / 3600);
+ var mins = Math.floor((total % 3600) / 60);
+ var secs = Math.floor(total % 60);
+
+ if (hrs > 0) {
+ return hrs + ":" + String(mins).padStart(2, "0") + ":" + String(secs).padStart(2, "0");
+ }
+ return mins + ":" + String(secs).padStart(2, "0");
+}
+
+function formatRelativeTime(value) {
+ var text = String(value || "").trim();
+ if (text.length === 0) {
+ return "";
+ }
+
+ var timestamp = new Date(text).getTime();
+ if (!isFinite(timestamp)) {
+ return text;
+ }
+
+ var diffMs = Date.now() - timestamp;
+ if (!isFinite(diffMs)) {
+ return text;
+ }
+ if (diffMs < 0) {
+ diffMs = 0;
+ }
+
+ var minutes = Math.floor(diffMs / 60000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return minutes + "m ago";
+
+ var hours = Math.floor(minutes / 60);
+ if (hours < 24) return hours + "h ago";
+
+ var days = Math.floor(hours / 24);
+ if (days < 7) return days + "d ago";
+
+ return text.slice(0, 10);
+}
diff --git a/.config/noctalia/plugins/music-search/Panel.qml b/.config/noctalia/plugins/music-search/Panel.qml
new file mode 100644
index 0000000..b5310bb
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/Panel.qml
@@ -0,0 +1,2645 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+import "MusicUtils.js" as MusicUtils
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings ?? ({})
+ readonly property var geometryPlaceholder: panelContainer
+ readonly property string helperPath: mainInstance?.helperPath || Qt.resolvedUrl("musicctl.sh").toString().replace("file://", "")
+ readonly property string commandName: ">" + (pluginApi?.manifest?.metadata?.commandPrefix || "music-search")
+ readonly property bool hasPlayback: mainInstance?.isPlaying === true || mainInstance?.playbackStarting === true
+ readonly property var filteredLibraryEntries: buildFilteredLibraryEntries()
+ readonly property var filteredPlaylistEntries: buildFilteredPlaylistEntries()
+ readonly property var filteredArtistStats: buildFilteredArtistStats()
+ readonly property var filteredTagStats: buildFilteredTagStats()
+ readonly property var recentLibraryEntries: buildRecentLibraryEntries()
+ readonly property var activeLibrarySelectionList: activeLibrarySelectionEntries()
+ readonly property var activeLibraryTrackList: hasLibrarySelection()
+ ? activeLibrarySelectionList
+ : (librarySection === "tracks" ? filteredLibraryEntries : [])
+ readonly property string defaultPanelTab: {
+ var value = (pluginApi?.pluginSettings?.defaultPanelTab
+ ?? defaults.defaultPanelTab
+ ?? "search");
+ value = value.trim().toLowerCase();
+ if (value === "library" || value === "queue") {
+ return value;
+ }
+ return "search";
+ }
+ readonly property string defaultPanelLibrarySection: {
+ var value = (pluginApi?.pluginSettings?.defaultPanelLibrarySection
+ ?? defaults.defaultPanelLibrarySection
+ ?? "tracks");
+ value = value.trim().toLowerCase();
+ if (value === "playlists" || value === "artists" || value === "tags") {
+ return value;
+ }
+ return "tracks";
+ }
+ readonly property string panelDensity: {
+ var value = (pluginApi?.pluginSettings?.panelDensity
+ ?? defaults.panelDensity
+ ?? "balanced");
+ value = value.trim().toLowerCase();
+ if (value === "compact" || value === "roomy") {
+ return value;
+ }
+ return "balanced";
+ }
+ readonly property bool showPanelHeader: pluginApi?.pluginSettings?.showPanelHeader
+ ?? defaults.showPanelHeader
+ ?? true
+ readonly property real panelSectionSpacing: panelDensity === "compact"
+ ? Style.marginS
+ : (panelDensity === "roomy" ? Style.marginL : Style.marginM)
+ readonly property real panelCardPadding: panelDensity === "compact"
+ ? Style.marginL
+ : (panelDensity === "roomy" ? Style.marginXL : Style.marginL)
+ readonly property real panelCardSpacing: panelDensity === "compact"
+ ? Style.marginXS
+ : (panelDensity === "roomy" ? Style.marginM : Style.marginS)
+ readonly property real panelCardHeaderSpacing: panelDensity === "compact"
+ ? Style.marginS
+ : (panelDensity === "roomy" ? Style.marginL : Style.marginM)
+ readonly property real panelCardRadius: panelDensity === "compact"
+ ? Style.radiusM
+ : (panelDensity === "roomy" ? (Style.radiusL + Style.marginXS) : Style.radiusL)
+ readonly property real panelTitleSize: panelDensity === "compact"
+ ? Style.fontSizeS
+ : (panelDensity === "roomy" ? Style.fontSizeL : Style.fontSizeM)
+ readonly property real panelBodySize: panelDensity === "compact"
+ ? Style.fontSizeXS
+ : (panelDensity === "roomy" ? Style.fontSizeM : Style.fontSizeS)
+ readonly property real panelButtonSize: panelDensity === "compact"
+ ? Style.fontSizeXS
+ : (panelDensity === "roomy" ? Style.fontSizeM : Style.fontSizeS)
+ readonly property real panelBadgeSize: panelDensity === "compact"
+ ? Style.fontSizeXS
+ : (panelDensity === "roomy" ? Style.fontSizeS : Style.fontSizeXS)
+ readonly property bool showPanelNowPlaying: pluginApi?.pluginSettings?.showPanelNowPlaying
+ ?? defaults.showPanelNowPlaying
+ ?? true
+ readonly property bool showPanelPlaybackProgress: pluginApi?.pluginSettings?.showPanelPlaybackProgress
+ ?? defaults.showPanelPlaybackProgress
+ ?? true
+ readonly property bool showPanelProviderChips: pluginApi?.pluginSettings?.showPanelProviderChips
+ ?? defaults.showPanelProviderChips
+ ?? true
+ readonly property bool showPanelRecentTracks: pluginApi?.pluginSettings?.showPanelRecentTracks
+ ?? defaults.showPanelRecentTracks
+ ?? true
+ readonly property bool showPanelSearchHelper: pluginApi?.pluginSettings?.showPanelSearchHelper
+ ?? defaults.showPanelSearchHelper
+ ?? true
+ readonly property bool showPanelPreview: pluginApi?.pluginSettings?.showPanelPreview
+ ?? defaults.showPanelPreview
+ ?? true
+ readonly property bool showPanelUrlActions: pluginApi?.pluginSettings?.showPanelUrlActions
+ ?? defaults.showPanelUrlActions
+ ?? true
+ readonly property bool showPanelSpeedControls: pluginApi?.pluginSettings?.showPanelSpeedControls
+ ?? defaults.showPanelSpeedControls
+ ?? true
+ readonly property bool showPanelQueueControls: pluginApi?.pluginSettings?.showPanelQueueControls
+ ?? defaults.showPanelQueueControls
+ ?? true
+ readonly property bool showPanelStatusBanner: pluginApi?.pluginSettings?.showPanelStatusBanner
+ ?? defaults.showPanelStatusBanner
+ ?? true
+ readonly property real previewPaneMinWidth: Math.round(240 * Style.uiScaleRatio)
+ readonly property real previewPaneMaxWidthCap: Math.round(420 * Style.uiScaleRatio)
+
+ property real contentPreferredWidth: 820 * Style.uiScaleRatio
+ property real contentPreferredHeight: 820 * Style.uiScaleRatio
+ readonly property bool allowAttach: true
+
+ property string activeTab: "search"
+ property string librarySection: "tracks"
+ property string searchText: ""
+ property string libraryFilterText: ""
+ property string selectedPlaylistId: ""
+ property string selectedArtistName: ""
+ property string selectedTagName: ""
+ property var searchResults: []
+ property string searchError: ""
+ property bool searchBusy: false
+ property string activeSearchQuery: ""
+ property string pendingSearchQuery: ""
+ property string lastCompletedQuery: ""
+ property string runningSearchQuery: ""
+ property string runningSearchProvider: ""
+ property int searchEpoch: 0
+ property int runningSearchEpoch: 0
+ property bool pendingSearchRestart: false
+ property bool seekDragging: false
+ property real localSeekRatio: -1
+ property var previewDetailCache: ({})
+ property var panelPreviewItem: null
+ property bool panelPreviewFollowsPlayback: true
+ property bool panelPreviewDismissed: false
+ property real previewPaneWidth: 0
+ property real preferredPreviewPaneWidth: 0
+
+ anchors.fill: parent
+
+ Process {
+ id: searchProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ var completedQuery = root.runningSearchQuery;
+ var staleSearch = root.runningSearchEpoch !== root.searchEpoch;
+
+ root.searchBusy = false;
+ root.searchError = "";
+
+ if (!staleSearch && exitCode === 0) {
+ try {
+ var parsed = JSON.parse(searchProcess.stdout.text || "[]");
+ root.searchResults = Array.isArray(parsed) ? parsed : [];
+ root.lastCompletedQuery = completedQuery;
+ } catch (error) {
+ root.searchResults = [];
+ root.lastCompletedQuery = completedQuery;
+ root.searchError = pluginApi?.tr("errors.searchMalformed");
+ }
+ } else if (!staleSearch) {
+ root.searchResults = [];
+ root.lastCompletedQuery = completedQuery;
+ root.searchError = (searchProcess.stderr.text || "").trim() || pluginApi?.tr("search.failed");
+ }
+
+ root.runningSearchQuery = "";
+ root.runningSearchProvider = "";
+
+ if (root.pendingSearchQuery && (root.pendingSearchRestart || root.pendingSearchQuery !== completedQuery)) {
+ var nextQuery = root.pendingSearchQuery;
+ root.pendingSearchQuery = "";
+ root.pendingSearchRestart = false;
+ root.startSearch(nextQuery);
+ }
+ }
+ }
+
+ Timer {
+ id: searchDelay
+ interval: 250
+ repeat: false
+ onTriggered: root.performSearch()
+ }
+
+ Connections {
+ target: mainInstance
+ ignoreUnknownSignals: true
+
+ function onCurrentProviderChanged() {
+ root.searchEpoch += 1;
+ root.searchResults = [];
+ root.lastCompletedQuery = "";
+ root.searchError = "";
+
+ if (root.searchBusy && root.trimmedSearchText().length > 0) {
+ if (root.pendingSearchQuery.length === 0) {
+ root.pendingSearchQuery = root.trimmedSearchText();
+ }
+ root.pendingSearchRestart = true;
+ } else if (root.activeTab === "search" && root.trimmedSearchText().length > 0 && !root.looksLikeUrl(root.trimmedSearchText())) {
+ searchDelay.restart();
+ }
+ }
+
+ function onIsPlayingChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onPlaybackStartingChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onCurrentEntryIdChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onCurrentUrlChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onCurrentTitleChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onCurrentUploaderChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+
+ function onCurrentDurationChanged() {
+ root.syncPanelPlaybackPreview(false);
+ }
+ }
+
+ onVisibleChanged: {
+ if (visible) {
+ activeTab = defaultPanelTab;
+ setLibrarySection(defaultPanelLibrarySection);
+ syncPanelPlaybackPreview(true);
+ mainInstance?.refreshStatus(true);
+ Qt.callLater(root.focusCurrentInput);
+ }
+ }
+
+ onShowPanelPreviewChanged: {
+ if (showPanelPreview && visible) {
+ syncPanelPlaybackPreview(true);
+ ensurePreviewPaneWidth(true);
+ }
+ }
+
+ onPanelPreviewItemChanged: {
+ if (panelPreviewItem) {
+ ensurePreviewPaneWidth(false);
+ }
+ }
+
+ onActiveTabChanged: {
+ if (visible) {
+ Qt.callLater(root.focusCurrentInput);
+ }
+ }
+
+ function focusCurrentInput() {
+ if (activeTab === "search") {
+ if (searchInput.inputItem) {
+ searchInput.inputItem.forceActiveFocus();
+ } else {
+ searchInput.forceActiveFocus();
+ }
+ return;
+ }
+
+ if (activeTab === "library") {
+ if (libraryFilterInput.inputItem) {
+ libraryFilterInput.inputItem.forceActiveFocus();
+ } else {
+ libraryFilterInput.forceActiveFocus();
+ }
+ }
+ }
+
+ function trimmedSearchText() {
+ return (searchText || "").trim();
+ }
+
+ function providerLabel(provider) {
+ return mainInstance?.providerLabel(provider)
+ || (provider === "soundcloud"
+ ? pluginApi?.tr("providers.soundcloud")
+ : (provider === "local"
+ ? pluginApi?.tr("providers.local")
+ : pluginApi?.tr("providers.youtube")));
+ }
+
+ function looksLikeUrl(value) {
+ var trimmed = (value || "").trim();
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || /^www\./i.test(trimmed);
+ }
+
+ function parseSearchProviderQuery(query) {
+ var raw = query || "";
+ var match = raw.match(/^(yt|youtube|sc|soundcloud|local):\s*(.*)$/i);
+ if (!match) {
+ return {
+ "provider": mainInstance?.currentProvider || "youtube",
+ "query": raw
+ };
+ }
+
+ var prefix = (match[1] || "").toLowerCase();
+ var provider = "youtube";
+ if (prefix === "sc" || prefix === "soundcloud") {
+ provider = "soundcloud";
+ } else if (prefix === "local") {
+ provider = "local";
+ }
+
+ return {
+ "provider": provider,
+ "query": (match[2] || "").trim()
+ };
+ }
+
+ function performSearch(forceImmediate) {
+ var query = trimmedSearchText();
+
+ if (looksLikeUrl(query) || query.length === 0) {
+ searchEpoch += 1;
+ searchResults = [];
+ searchError = "";
+ activeSearchQuery = "";
+ pendingSearchQuery = "";
+ pendingSearchRestart = false;
+ lastCompletedQuery = "";
+ return;
+ }
+
+ var parsed = parseSearchProviderQuery(query);
+ var resolvedQuery = (parsed.query || "").trim();
+ if (resolvedQuery.length < 2) {
+ searchEpoch += 1;
+ searchResults = [];
+ searchError = "";
+ activeSearchQuery = query;
+ pendingSearchQuery = "";
+ pendingSearchRestart = false;
+ return;
+ }
+
+ if (forceImmediate === true) {
+ if (searchBusy) {
+ pendingSearchQuery = query;
+ pendingSearchRestart = true;
+ return;
+ }
+ startSearch(query);
+ return;
+ }
+
+ if (searchBusy) {
+ pendingSearchQuery = query;
+ return;
+ }
+
+ startSearch(query);
+ }
+
+ function startSearch(query) {
+ if (!helperPath) {
+ return;
+ }
+
+ var parsed = parseSearchProviderQuery(query);
+ var provider = parsed.provider;
+ var resolvedQuery = (parsed.query || "").trim();
+
+ if (resolvedQuery.length < 2) {
+ searchResults = [];
+ searchError = "";
+ return;
+ }
+
+ activeSearchQuery = query;
+ pendingSearchQuery = "";
+ pendingSearchRestart = false;
+ searchBusy = true;
+ searchError = "";
+ runningSearchQuery = query;
+ runningSearchProvider = provider;
+ runningSearchEpoch = searchEpoch;
+ searchProcess.exec({
+ "command": ["bash", helperPath, "search", resolvedQuery, provider]
+ });
+ }
+
+ function normalizedEntry(entry) {
+ return {
+ "id": entry?.id || "",
+ "title": entry?.title || entry?.name || pluginApi?.tr("common.untitled"),
+ "url": entry?.url || "",
+ "uploader": entry?.uploader || "",
+ "duration": entry?.duration || 0,
+ "provider": entry?.provider || "",
+ "album": entry?.album || "",
+ "tags": Array.isArray(entry?.tags) ? entry.tags : [],
+ "rating": entry?.rating || 0,
+ "playCount": entry?.playCount || 0,
+ "savedAt": entry?.savedAt || "",
+ "lastPlayedAt": entry?.lastPlayedAt || "",
+ "queuedAt": entry?.queuedAt || ""
+ };
+ }
+
+ function compareIsoStringsDesc(a, b) {
+ var aValue = a || "";
+ var bValue = b || "";
+ if (aValue === bValue) {
+ return 0;
+ }
+ return aValue > bValue ? -1 : 1;
+ }
+
+ function buildFilteredLibraryEntries() {
+ var entries = sortLibraryEntries((mainInstance?.visibleLibraryEntries() || []).slice());
+ var query = (libraryFilterText || "").trim().toLowerCase();
+
+ if (query.length === 0) {
+ return entries;
+ }
+
+ return entries.filter(function (entry) {
+ var haystack = [
+ entry?.title || "",
+ entry?.uploader || "",
+ entry?.album || "",
+ (entry?.tags || []).join(" ") || ""
+ ].join(" ").toLowerCase();
+ return haystack.indexOf(query) >= 0;
+ });
+ }
+
+ function sortLibraryEntries(entries) {
+ var mode = mainInstance?.currentSortBy || "date";
+
+ entries.sort(function (left, right) {
+ if (mode === "title") {
+ return (left?.title || "").localeCompare(right?.title || "");
+ }
+ if (mode === "duration") {
+ return (right?.duration || 0) - (left?.duration || 0);
+ }
+ if (mode === "rating") {
+ var ratingDiff = (right?.rating || 0) - (left?.rating || 0);
+ if (ratingDiff !== 0) {
+ return ratingDiff;
+ }
+ }
+ return compareIsoStringsDesc(left?.savedAt, right?.savedAt);
+ });
+
+ return entries;
+ }
+
+ function buildFilteredPlaylistEntries() {
+ var playlists = (mainInstance?.playlistEntries || []).slice();
+ var query = (libraryFilterText || "").trim().toLowerCase();
+
+ playlists.sort(function (left, right) {
+ var leftDate = left?.createdAt || "";
+ var rightDate = right?.createdAt || "";
+ if (leftDate !== rightDate) {
+ return compareIsoStringsDesc(leftDate, rightDate);
+ }
+ return (left?.name || "").localeCompare(right?.name || "");
+ });
+
+ if (query.length === 0) {
+ return playlists;
+ }
+
+ return playlists.filter(function (playlist) {
+ var haystack = [
+ playlist?.name || "",
+ playlist?.sourceFolder || ""
+ ].join(" ").toLowerCase();
+ return haystack.indexOf(query) >= 0;
+ });
+ }
+
+ function collectArtistStats() {
+ var seen = ({});
+ var stats = [];
+ var library = mainInstance?.visibleLibraryEntries() || [];
+
+ for (var i = 0; i < library.length; i++) {
+ var artist = (library[i].uploader || "").trim();
+ var key = artist.toLowerCase();
+ if (key.length === 0) {
+ continue;
+ }
+ if (!seen[key]) {
+ seen[key] = {
+ "name": artist,
+ "count": 0,
+ "playCount": 0,
+ "lastPlayedAt": ""
+ };
+ stats.push(seen[key]);
+ }
+ seen[key].count += 1;
+ seen[key].playCount += Number(library[i].playCount || 0);
+ var playedAt = (library[i].lastPlayedAt || "");
+ if (playedAt.length > 0 && playedAt > seen[key].lastPlayedAt) {
+ seen[key].lastPlayedAt = playedAt;
+ }
+ }
+
+ stats.sort(function (a, b) {
+ if (b.count !== a.count) {
+ return b.count - a.count;
+ }
+ if (b.playCount !== a.playCount) {
+ return b.playCount - a.playCount;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ return stats;
+ }
+
+ function normalizeTagValue(tag) {
+ var value = String(tag || "").trim();
+ return value.replace(/^#+/, "");
+ }
+
+ function collectTagStats() {
+ var seen = ({});
+ var stats = [];
+ var library = mainInstance?.visibleLibraryEntries() || [];
+
+ for (var i = 0; i < library.length; i++) {
+ var entryTags = library[i].tags || [];
+ for (var j = 0; j < entryTags.length; j++) {
+ var normalizedTag = normalizeTagValue(entryTags[j]);
+ var key = normalizedTag.toLowerCase();
+ if (key.length === 0) {
+ continue;
+ }
+ if (!seen[key]) {
+ seen[key] = {
+ "tag": normalizedTag,
+ "count": 0
+ };
+ stats.push(seen[key]);
+ }
+ seen[key].count += 1;
+ }
+ }
+
+ stats.sort(function (a, b) {
+ if (b.count !== a.count) {
+ return b.count - a.count;
+ }
+ return a.tag.localeCompare(b.tag);
+ });
+ return stats;
+ }
+
+ function buildFilteredArtistStats() {
+ var artists = collectArtistStats();
+ var query = (libraryFilterText || "").trim().toLowerCase();
+ if (query.length === 0) {
+ return artists;
+ }
+
+ return artists.filter(function (artist) {
+ return (artist?.name || "").toLowerCase().indexOf(query) >= 0;
+ });
+ }
+
+ function buildFilteredTagStats() {
+ var tags = collectTagStats();
+ var query = normalizeTagValue(libraryFilterText).toLowerCase();
+ if (query.length === 0) {
+ return tags;
+ }
+
+ return tags.filter(function (tagStat) {
+ return (tagStat?.tag || "").toLowerCase().indexOf(query) >= 0;
+ });
+ }
+
+ function resolvePlaylistEntry(playlist) {
+ var playlistId = String(playlist?.id || "").trim();
+ if (playlistId.length > 0) {
+ var resolved = mainInstance?.findPlaylistById(playlistId);
+ if (resolved) {
+ return resolved;
+ }
+ }
+ return playlist || null;
+ }
+
+ function playlistEntryCount(playlist) {
+ var resolved = resolvePlaylistEntry(playlist);
+ return Array.isArray(resolved?.entryIds) ? resolved.entryIds.length : 0;
+ }
+
+ function playlistDetailEntries(playlistId) {
+ var targetId = String(playlistId || "").trim();
+ if (targetId.length === 0) {
+ return [];
+ }
+
+ var targetPlaylist = resolvePlaylistEntry({"id": targetId});
+
+ if (!targetPlaylist) {
+ return [];
+ }
+
+ var library = mainInstance?.libraryEntries || [];
+ var tracks = [];
+ var entryIds = targetPlaylist.entryIds || [];
+ for (var j = 0; j < entryIds.length; j++) {
+ var entryId = String(entryIds[j] || "");
+ for (var k = 0; k < library.length; k++) {
+ if (String(library[k]?.id || "") === entryId) {
+ tracks.push(library[k]);
+ break;
+ }
+ }
+ }
+
+ return sortLibraryEntries(tracks);
+ }
+
+ function artistDetailEntries(artistName) {
+ var target = (artistName || "").trim().toLowerCase();
+ if (target.length === 0) {
+ return [];
+ }
+
+ return sortLibraryEntries((mainInstance?.visibleLibraryEntries() || []).filter(function (entry) {
+ return (entry?.uploader || "").trim().toLowerCase() === target;
+ }).slice());
+ }
+
+ function tagDetailEntries(tagName) {
+ var target = normalizeTagValue(tagName).toLowerCase();
+ if (target.length === 0) {
+ return [];
+ }
+
+ return sortLibraryEntries((mainInstance?.visibleLibraryEntries() || []).filter(function (entry) {
+ var tags = Array.isArray(entry?.tags) ? entry.tags : [];
+ for (var i = 0; i < tags.length; i++) {
+ if (normalizeTagValue(tags[i]).toLowerCase() === target) {
+ return true;
+ }
+ }
+ return false;
+ }).slice());
+ }
+
+ function buildRecentLibraryEntries() {
+ var entries = (mainInstance?.visibleLibraryEntries() || []).slice();
+ entries.sort(function (left, right) {
+ var leftDate = left?.lastPlayedAt || left?.savedAt || "";
+ var rightDate = right?.lastPlayedAt || right?.savedAt || "";
+ return compareIsoStringsDesc(leftDate, rightDate);
+ });
+ return entries.slice(0, 10);
+ }
+
+ function librarySectionLabel(section) {
+ if (section === "playlists") {
+ return pluginApi?.tr("panel.playlists");
+ }
+ if (section === "artists") {
+ return pluginApi?.tr("panel.artists");
+ }
+ if (section === "tags") {
+ return pluginApi?.tr("panel.tags");
+ }
+ return pluginApi?.tr("panel.tracks");
+ }
+
+ function libraryPlaceholderText() {
+ if (librarySection === "playlists") {
+ return pluginApi?.tr("panel.playlistsPlaceholder");
+ }
+ if (librarySection === "artists") {
+ return pluginApi?.tr("panel.artistsPlaceholder");
+ }
+ if (librarySection === "tags") {
+ return pluginApi?.tr("panel.tagsPlaceholder");
+ }
+ return pluginApi?.tr("panel.libraryPlaceholder");
+ }
+
+ function setLibrarySection(section) {
+ librarySection = section || "tracks";
+ selectedPlaylistId = "";
+ selectedArtistName = "";
+ selectedTagName = "";
+ libraryFilterText = "";
+ }
+
+ function hasLibrarySelection() {
+ return selectedPlaylistId.length > 0 || selectedArtistName.length > 0 || selectedTagName.length > 0;
+ }
+
+ function activeLibrarySelectionTitle() {
+ if (selectedPlaylistId.length > 0) {
+ var playlist = mainInstance?.findPlaylistById(selectedPlaylistId);
+ return playlist?.name || pluginApi?.tr("panel.playlists");
+ }
+ if (selectedArtistName.length > 0) {
+ return selectedArtistName;
+ }
+ if (selectedTagName.length > 0) {
+ return "#" + selectedTagName;
+ }
+ return "";
+ }
+
+ function activeLibrarySelectionEntries() {
+ if (selectedPlaylistId.length > 0) {
+ return playlistDetailEntries(selectedPlaylistId);
+ }
+ if (selectedArtistName.length > 0) {
+ return artistDetailEntries(selectedArtistName);
+ }
+ if (selectedTagName.length > 0) {
+ return tagDetailEntries(selectedTagName);
+ }
+ return [];
+ }
+
+ function openPlaylistSection(playlistId) {
+ selectedPlaylistId = String(playlistId || "").trim();
+ selectedArtistName = "";
+ selectedTagName = "";
+ }
+
+ function openArtistSection(artistName) {
+ selectedArtistName = String(artistName || "").trim();
+ selectedPlaylistId = "";
+ selectedTagName = "";
+ }
+
+ function openTagSection(tagName) {
+ selectedTagName = normalizeTagValue(tagName);
+ selectedPlaylistId = "";
+ selectedArtistName = "";
+ }
+
+ function clearLibrarySelection() {
+ selectedPlaylistId = "";
+ selectedArtistName = "";
+ selectedTagName = "";
+ }
+
+ function currentLibraryActionEntries(shuffle) {
+ var entries = activeLibrarySelectionList.slice();
+ return shuffle === true ? (mainInstance?.shuffleEntries(entries) || entries) : entries;
+ }
+
+ function playCurrentLibrarySelection(shuffle) {
+ var entries = currentLibraryActionEntries(shuffle);
+ mainInstance?.startQueueBatch(entries, {
+ "clearFirst": true,
+ "finalAction": "skip",
+ "emptyNotice": pluginApi?.tr("panel.emptyScopedLibrary")
+ });
+ }
+
+ function queueCurrentLibrarySelection(shuffle) {
+ var entries = currentLibraryActionEntries(shuffle);
+ mainInstance?.startQueueBatch(entries, {
+ "clearFirst": false,
+ "emptyNotice": pluginApi?.tr("panel.emptyScopedLibrary")
+ });
+ }
+
+ function formatRating(rating) {
+ var value = rating || 0;
+ if (!isFinite(value) || value <= 0) {
+ return "";
+ }
+
+ var stars = "";
+ for (var i = 0; i < value; i++) {
+ stars += "\u2605";
+ }
+ return stars;
+ }
+
+ function formatPlayCount(count) {
+ var plays = count || 0;
+ if (!isFinite(plays) || plays <= 0) {
+ return "";
+ }
+ return plays === 1 ? pluginApi?.tr("common.onePlay") : pluginApi?.tr("common.plays", {"count": plays});
+ }
+
+ function formatSpeed(speed) {
+ var value = speed || 1;
+ if (!isFinite(value)) {
+ return pluginApi?.tr("speed.multiplier", {"speed": "1.00"});
+ }
+
+ var rounded = Math.round(value * 100) / 100;
+ return pluginApi?.tr("speed.multiplier", {"speed": rounded.toFixed(2)});
+ }
+
+ function effectiveSeekPosition() {
+ var duration = mainInstance?.currentDuration || 0;
+ if (seekDragging && localSeekRatio >= 0 && duration > 0) {
+ return Math.max(0, Math.min(duration, localSeekRatio * duration));
+ }
+ return Math.max(0, mainInstance?.currentPosition || 0);
+ }
+
+ function entrySummary(entry, section) {
+ var normalized = normalizedEntry(entry);
+ var parts = [];
+
+ if (normalized.uploader.length > 0) {
+ parts.push(normalized.uploader);
+ }
+
+ var duration = MusicUtils.formatDuration(normalized.duration);
+ if (duration.length > 0) {
+ parts.push(duration);
+ }
+
+ if (section === "search") {
+ parts.push(providerLabel(normalized.provider || parseSearchProviderQuery(trimmedSearchText()).provider));
+ } else if (section === "library") {
+ var rating = formatRating(normalized.rating);
+ if (rating.length > 0) {
+ parts.push(rating);
+ }
+ var playCount = formatPlayCount(normalized.playCount);
+ if (playCount.length > 0) {
+ parts.push(playCount);
+ }
+ if (normalized.tags.length > 0) {
+ parts.push(normalized.tags.map(function (tag) { return "#" + tag; }).join(" "));
+ }
+ } else if (section === "queue") {
+ var queuedAt = MusicUtils.formatRelativeTime(normalized.queuedAt);
+ if (queuedAt.length > 0) {
+ parts.push(pluginApi?.tr("panel.queuedAt", {"time": queuedAt}));
+ }
+ }
+
+ return parts.join(" • ");
+ }
+
+ function isCurrentEntry(entry) {
+ var normalized = normalizedEntry(entry);
+ if (!hasPlayback) {
+ return false;
+ }
+ if (normalized.id.length > 0 && normalized.id === (mainInstance?.currentEntryId || "")) {
+ return true;
+ }
+ return normalized.url.length > 0 && normalized.url === (mainInstance?.currentUrl || "");
+ }
+
+ function isRemoteEntry(entry) {
+ var normalized = normalizedEntry(entry);
+ return normalized.url.length > 0 && !normalized.url.startsWith("/");
+ }
+
+ function closePanel() {
+ var screen = pluginApi?.panelOpenScreen;
+ if (screen) {
+ pluginApi.closePanel(screen);
+ return;
+ }
+
+ pluginApi?.withCurrentScreen(function (currentScreen) {
+ pluginApi.closePanel(currentScreen);
+ });
+ }
+
+ function openSettings() {
+ var screen = pluginApi?.panelOpenScreen;
+ if (screen) {
+ BarService.openPluginSettings(screen, pluginApi.manifest);
+ return;
+ }
+
+ pluginApi?.withCurrentScreen(function (currentScreen) {
+ BarService.openPluginSettings(currentScreen, pluginApi.manifest);
+ });
+ }
+
+ function previewItemsEqual(left, right) {
+ var leftId = String(left?.id || "").trim();
+ var rightId = String(right?.id || "").trim();
+ if (leftId.length > 0 && rightId.length > 0) {
+ return leftId === rightId;
+ }
+
+ var leftUrl = String(left?.url || "").trim();
+ var rightUrl = String(right?.url || "").trim();
+ return leftUrl.length > 0 && leftUrl === rightUrl;
+ }
+
+ function buildPanelPreviewItem(entry, section) {
+ var normalized = normalizedEntry(entry);
+ if ((normalized.id || "").length === 0 && (normalized.url || "").length === 0) {
+ return null;
+ }
+
+ var providerKey = normalized.provider || mainInstance?.currentProvider || "youtube";
+ return {
+ "id": normalized.id,
+ "name": normalized.title,
+ "title": normalized.title,
+ "url": normalized.url,
+ "uploader": normalized.uploader,
+ "duration": normalized.duration,
+ "album": normalized.album,
+ "tags": normalized.tags.slice(),
+ "helperPath": helperPath,
+ "previewDelayMs": 350,
+ "provider": root,
+ "sourceLabel": providerLabel(providerKey),
+ "isSaved": mainInstance?.isSaved(normalized) === true,
+ "isPlaying": isCurrentEntry(normalized),
+ "isStarting": isCurrentEntry(normalized) && mainInstance?.playbackStarting === true
+ };
+ }
+
+ function playbackPreviewEntry() {
+ if (!hasPlayback) {
+ return null;
+ }
+
+ return buildPanelPreviewItem({
+ "id": mainInstance?.currentEntryId || "",
+ "title": mainInstance?.currentTitle || "",
+ "url": mainInstance?.currentUrl || "",
+ "uploader": mainInstance?.currentUploader || "",
+ "duration": mainInstance?.currentDuration || 0,
+ "provider": mainInstance?.currentProvider || ""
+ }, "queue");
+ }
+
+ function setPanelPreviewEntry(entry, section) {
+ var nextItem = buildPanelPreviewItem(entry, section);
+ if (!nextItem) {
+ return;
+ }
+
+ if (!panelPreviewFollowsPlayback && previewItemsEqual(panelPreviewItem, nextItem)) {
+ clearPanelPreview();
+ return;
+ }
+
+ panelPreviewItem = nextItem;
+ panelPreviewFollowsPlayback = false;
+ panelPreviewDismissed = false;
+ }
+
+ function clearPanelPreview() {
+ panelPreviewItem = null;
+ panelPreviewFollowsPlayback = false;
+ panelPreviewDismissed = true;
+ }
+
+ function syncPanelPlaybackPreview(force) {
+ if (!showPanelPreview) {
+ return;
+ }
+
+ if (force === true) {
+ panelPreviewDismissed = false;
+ } else if (panelPreviewDismissed) {
+ return;
+ }
+
+ if (!hasPlayback) {
+ if (force === true || panelPreviewFollowsPlayback) {
+ panelPreviewItem = null;
+ }
+ return;
+ }
+
+ if (force === true || panelPreviewFollowsPlayback || !panelPreviewItem) {
+ panelPreviewItem = playbackPreviewEntry();
+ panelPreviewFollowsPlayback = true;
+ }
+ }
+
+ function clampPreviewPaneWidth(value) {
+ var availableWidth = panelTabsRow?.width || 0;
+ var maxWidth = availableWidth > 0
+ ? Math.min(previewPaneMaxWidthCap, Math.max(previewPaneMinWidth, availableWidth * 0.48))
+ : previewPaneMaxWidthCap;
+ return Math.max(previewPaneMinWidth, Math.min(maxWidth, value || 0));
+ }
+
+ function persistPreviewPaneWidth(value, flush) {
+ var rawWidth = Number(value || 0);
+ if (!isFinite(rawWidth) || rawWidth <= 0) {
+ return previewPaneWidth;
+ }
+ preferredPreviewPaneWidth = rawWidth;
+ var nextWidth = clampPreviewPaneWidth(rawWidth);
+ previewPaneWidth = nextWidth;
+ if (pluginApi?.pluginSettings) {
+ pluginApi.pluginSettings.previewPaneWidth = rawWidth;
+ }
+ if (flush === true && pluginApi) {
+ pluginApi.saveSettings();
+ }
+ return nextWidth;
+ }
+
+ function savedPreviewPaneWidth() {
+ var value = Number(pluginApi?.pluginSettings?.previewPaneWidth
+ ?? defaults.previewPaneWidth
+ ?? 0);
+ if (!isFinite(value) || value <= 0) {
+ return 0;
+ }
+ return value;
+ }
+
+ function ensurePreviewPaneWidth(force) {
+ if (!showPanelPreview || !panelPreviewItem) {
+ return;
+ }
+
+ if (force === true || preferredPreviewPaneWidth <= 0) {
+ preferredPreviewPaneWidth = savedPreviewPaneWidth();
+ }
+
+ if (force === true || previewPaneWidth <= 0) {
+ if (preferredPreviewPaneWidth > 0) {
+ previewPaneWidth = clampPreviewPaneWidth(preferredPreviewPaneWidth);
+ return;
+ }
+ var availableWidth = panelTabsRow?.width || 0;
+ var fallbackWidth = availableWidth > 0
+ ? Math.round(availableWidth * 0.34)
+ : Math.round(320 * Style.uiScaleRatio);
+ preferredPreviewPaneWidth = fallbackWidth;
+ previewPaneWidth = clampPreviewPaneWidth(preferredPreviewPaneWidth);
+ return;
+ }
+
+ if (preferredPreviewPaneWidth > 0) {
+ previewPaneWidth = clampPreviewPaneWidth(preferredPreviewPaneWidth);
+ } else {
+ previewPaneWidth = clampPreviewPaneWidth(previewPaneWidth);
+ }
+ }
+
+ component ProviderChip: Rectangle {
+ id: chip
+
+ property string providerKey: "youtube"
+ readonly property bool active: (root.mainInstance?.currentProvider || "youtube") === providerKey
+
+ radius: Style.radiusM
+ color: active ? (Color.mPrimaryContainer || Qt.alpha(Color.mPrimary, 0.14) || Color.mSurfaceVariant) : Color.mSurfaceVariant
+ border.width: 1
+ border.color: active ? Color.mPrimary : Qt.alpha((Color.mOutline || Color.mOnSurfaceVariant || "#888888"), 0.35)
+ implicitWidth: providerLabelText.implicitWidth + (Style.marginL * 2)
+ implicitHeight: providerLabelText.implicitHeight + (Style.marginS * 2)
+
+ NText {
+ id: providerLabelText
+ anchors.centerIn: parent
+ text: root.providerLabel(chip.providerKey)
+ color: chip.active ? (Color.mOnPrimaryContainer || Color.mOnSurface) : Color.mOnSurface
+ pointSize: Style.fontSizeS
+ font.weight: chip.active ? Font.DemiBold : Font.Normal
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.mainInstance?.setProvider(chip.providerKey)
+ }
+ }
+
+ component TrackCard: Rectangle {
+ id: card
+
+ property var entry: null
+ property string section: "search"
+
+ readonly property var normalized: root.normalizedEntry(entry)
+ readonly property bool saved: root.mainInstance?.isSaved(normalized) === true
+ readonly property bool current: root.isCurrentEntry(normalized)
+ readonly property bool remoteEntry: root.isRemoteEntry(normalized)
+ readonly property bool previewSelected: root.previewItemsEqual(root.panelPreviewItem, normalized)
+
+ Layout.fillWidth: true
+ radius: root.panelCardRadius
+ color: current ? (Color.mSurface || Color.mSurfaceVariant) : Color.mSurfaceVariant
+ border.width: (current || previewSelected) ? Style.borderS : 0
+ border.color: current
+ ? (Color.mPrimary || Color.mOnSurface)
+ : (previewSelected ? Qt.alpha((Color.mPrimary || Color.mOnSurface), 0.5) : "transparent")
+ implicitHeight: content.implicitHeight + (root.panelCardPadding * 2)
+
+ ColumnLayout {
+ id: content
+ anchors.fill: parent
+ anchors.margins: root.panelCardPadding
+ spacing: root.panelCardSpacing
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: headerRow.implicitHeight
+
+ RowLayout {
+ id: headerRow
+ anchors.fill: parent
+ spacing: root.panelCardHeaderSpacing
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Math.max(Style.marginXXS, Math.round(root.panelCardSpacing * 0.5))
+
+ NText {
+ Layout.fillWidth: true
+ text: normalized.title
+ color: Color.mOnSurface
+ pointSize: root.panelTitleSize
+ font.weight: Font.DemiBold
+ elide: Text.ElideRight
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.entrySummary(normalized, section)
+ visible: text.length > 0
+ color: Color.mOnSurfaceVariant
+ pointSize: root.panelBodySize
+ wrapMode: Text.Wrap
+ }
+ }
+
+ Rectangle {
+ visible: saved
+ radius: Style.radiusM
+ color: current ? Qt.alpha(Color.mPrimary, 0.16) : Qt.alpha(Color.mPrimary, 0.12)
+ implicitWidth: savedLabel.implicitWidth + (Style.marginM * 2)
+ implicitHeight: savedLabel.implicitHeight + (Style.marginXS * 2)
+
+ NText {
+ id: savedLabel
+ anchors.centerIn: parent
+ text: root.pluginApi?.tr("panel.savedLabel")
+ color: Color.mPrimary
+ pointSize: root.panelBadgeSize
+ font.weight: Font.DemiBold
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.setPanelPreviewEntry(normalized, section)
+ }
+ }
+
+ Flow {
+ Layout.fillWidth: true
+ width: parent.width
+ spacing: root.panelCardSpacing
+
+ NButton {
+ text: current
+ ? (root.mainInstance?.isPaused === true
+ ? root.pluginApi?.tr("panel.resume")
+ : root.pluginApi?.tr("panel.pause"))
+ : root.pluginApi?.tr("panel.playAction")
+ icon: current
+ ? (root.mainInstance?.isPaused === true ? "player-play-filled" : "player-pause-filled")
+ : "player-play-filled"
+ fontSize: root.panelButtonSize
+ onClicked: {
+ if (current) {
+ root.mainInstance?.togglePause();
+ } else if (section === "queue") {
+ root.mainInstance?.playQueueEntryNow(normalized);
+ } else {
+ root.mainInstance?.playEntry(normalized);
+ }
+ }
+ }
+
+ NButton {
+ text: root.pluginApi?.tr("panel.queueAction")
+ icon: "list"
+ fontSize: root.panelButtonSize
+ visible: section !== "queue"
+ onClicked: root.mainInstance?.enqueueEntry(normalized)
+ }
+
+ NButton {
+ text: root.pluginApi?.tr("panel.saveAction")
+ icon: "bookmark-plus"
+ fontSize: root.panelButtonSize
+ visible: !saved && section !== "queue"
+ onClicked: root.mainInstance?.saveEntry(normalized)
+ }
+
+ NButton {
+ text: root.pluginApi?.tr("panel.downloadAction")
+ icon: "download"
+ fontSize: root.panelButtonSize
+ visible: section !== "queue" && remoteEntry
+ onClicked: root.mainInstance?.downloadEntry(normalized)
+ }
+
+ NButton {
+ text: root.pluginApi?.tr("panel.removeAction")
+ icon: "trash"
+ fontSize: root.panelButtonSize
+ visible: section === "queue" || section === "library"
+ onClicked: {
+ if (section === "queue") {
+ root.mainInstance?.removeQueueEntry(normalized.id, true);
+ } else {
+ root.mainInstance?.removeEntry(normalized.id);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ component LibraryBrowseCard: Rectangle {
+ id: browseCard
+
+ property string title: ""
+ property string description: ""
+ property string accentText: ""
+ property string iconText: ""
+ property var primaryAction: null
+ property var secondaryAction: null
+ property var tertiaryAction: null
+ property var quaternaryAction: null
+
+ Layout.fillWidth: true
+ radius: root.panelCardRadius
+ color: Color.mSurfaceVariant
+ implicitHeight: browseContent.implicitHeight + (root.panelCardPadding * 2)
+
+ ColumnLayout {
+ id: browseContent
+ anchors.fill: parent
+ anchors.margins: root.panelCardPadding
+ spacing: root.panelCardSpacing
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: root.panelCardHeaderSpacing
+
+ Rectangle {
+ visible: browseCard.iconText.length > 0
+ radius: Style.radiusM
+ color: Qt.alpha(Color.mPrimary, 0.14)
+ implicitWidth: Math.max(iconLabel.implicitWidth, Math.round(24 * Style.uiScaleRatio)) + Style.marginS
+ implicitHeight: Math.max(iconLabel.implicitHeight, Math.round(24 * Style.uiScaleRatio)) + Style.marginXS
+
+ NText {
+ id: iconLabel
+ anchors.centerIn: parent
+ text: browseCard.iconText
+ color: Color.mPrimary
+ pointSize: Style.fontSizeS
+ font.weight: Font.DemiBold
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Math.max(Style.marginXXS, Math.round(root.panelCardSpacing * 0.5))
+
+ NText {
+ Layout.fillWidth: true
+ text: browseCard.title
+ color: Color.mOnSurface
+ pointSize: root.panelTitleSize
+ font.weight: Font.DemiBold
+ wrapMode: Text.Wrap
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: browseCard.description.length > 0
+ text: browseCard.description
+ color: Color.mOnSurfaceVariant
+ pointSize: root.panelBodySize
+ wrapMode: Text.Wrap
+ }
+ }
+
+ Rectangle {
+ visible: browseCard.accentText.length > 0
+ radius: Style.radiusM
+ color: Qt.alpha(Color.mPrimary, 0.18)
+ implicitWidth: accentLabel.implicitWidth + (Style.marginM * 2)
+ implicitHeight: accentLabel.implicitHeight + (Style.marginXS * 2)
+
+ NText {
+ id: accentLabel
+ anchors.centerIn: parent
+ text: browseCard.accentText
+ color: Color.mPrimary
+ pointSize: root.panelBodySize
+ font.weight: Font.DemiBold
+ }
+ }
+ }
+
+ Flow {
+ Layout.fillWidth: true
+ spacing: root.panelCardSpacing
+
+ Repeater {
+ model: [browseCard.primaryAction, browseCard.secondaryAction, browseCard.tertiaryAction, browseCard.quaternaryAction]
+
+ delegate: NButton {
+ required property var modelData
+
+ visible: !!modelData
+ text: modelData?.text || ""
+ icon: modelData?.icon || ""
+ fontSize: root.panelButtonSize
+ onClicked: {
+ if (modelData?.onClicked) {
+ modelData.onClicked();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: root.panelSectionSpacing
+
+ Rectangle {
+ visible: root.showPanelHeader
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Color.mSurfaceVariant
+ implicitHeight: headerContent.implicitHeight + (Style.marginL * 2)
+
+ RowLayout {
+ id: headerContent
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: Style.marginM
+
+ NIcon {
+ icon: "music"
+ pointSize: Style.fontSizeXXL
+ color: Color.mPrimary
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+
+ NText {
+ text: pluginApi?.tr("panel.title")
+ color: Color.mOnSurface
+ pointSize: Style.fontSizeL
+ font.weight: Font.Bold
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("panel.subtitle")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.openLauncher")
+ icon: "search"
+ fontSize: Style.fontSizeS
+ onClicked: {
+ root.closePanel();
+ mainInstance?.openLauncher();
+ }
+ }
+
+ NIconButton {
+ icon: "settings"
+ tooltipText: pluginApi?.tr("panel.openSettings")
+ onClicked: root.openSettings()
+ }
+
+ NIconButton {
+ icon: "x"
+ tooltipText: pluginApi?.tr("panel.close")
+ onClicked: root.closePanel()
+ }
+ }
+ }
+
+ Rectangle {
+ visible: root.showPanelNowPlaying
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Color.mSurfaceVariant
+ implicitHeight: playbackColumn.implicitHeight + (Style.marginL * 2)
+
+ ColumnLayout {
+ id: playbackColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: root.panelCardSpacing
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+
+ NText {
+ text: pluginApi?.tr("panel.nowPlaying")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ font.weight: Font.DemiBold
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: {
+ if (mainInstance?.playbackStarting === true && (mainInstance?.currentTitle || "").trim().length === 0) {
+ return pluginApi?.tr("status.starting");
+ }
+ return (mainInstance?.currentTitle || "").trim() || pluginApi?.tr("panel.nothingPlaying");
+ }
+ color: Color.mOnSurface
+ pointSize: Style.fontSizeM
+ font.weight: Font.DemiBold
+ wrapMode: Text.Wrap
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: root.hasPlayback
+ text: {
+ var parts = [];
+ var uploader = (mainInstance?.currentUploader || "").trim();
+ var provider = root.providerLabel(mainInstance?.currentProvider || "youtube");
+ var duration = MusicUtils.formatDuration(mainInstance?.currentDuration || 0);
+ if (uploader.length > 0) {
+ parts.push(uploader);
+ }
+ if (provider.length > 0) {
+ parts.push(provider);
+ }
+ if (duration.length > 0) {
+ parts.push(duration);
+ }
+ return parts.join(" • ");
+ }
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+ }
+
+ NButton {
+ text: mainInstance?.isPaused === true ? pluginApi?.tr("panel.resume") : pluginApi?.tr("panel.pause")
+ icon: mainInstance?.isPaused === true ? "player-play-filled" : "player-pause-filled"
+ fontSize: Style.fontSizeS
+ enabled: mainInstance?.isPlaying === true
+ onClicked: mainInstance?.togglePause()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.stop")
+ icon: "player-stop-filled"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback
+ onClicked: mainInstance?.stopPlayback()
+ }
+ }
+
+ NSlider {
+ id: playbackSlider
+ visible: root.showPanelPlaybackProgress
+ Layout.fillWidth: true
+ from: 0
+ to: 1
+ stepSize: 0
+ snapAlways: false
+ heightRatio: 0.4
+ enabled: mainInstance?.isPlaying === true && (mainInstance?.currentDuration || 0) > 0
+ value: {
+ var duration = mainInstance?.currentDuration || 0;
+ if (!isFinite(duration) || duration <= 0) {
+ return 0;
+ }
+ if (root.seekDragging && root.localSeekRatio >= 0) {
+ return Math.max(0, Math.min(1, root.localSeekRatio));
+ }
+ return Math.max(0, Math.min(1, (mainInstance?.currentPosition || 0) / duration));
+ }
+
+ onMoved: {
+ root.seekDragging = true;
+ root.localSeekRatio = value;
+ }
+ onPressedChanged: {
+ if (pressed) {
+ root.seekDragging = true;
+ root.localSeekRatio = value;
+ } else {
+ if (enabled) {
+ root.mainInstance?.seekToRatio(value);
+ }
+ root.seekDragging = false;
+ root.localSeekRatio = -1;
+ }
+ }
+ }
+
+ RowLayout {
+ visible: root.showPanelPlaybackProgress
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NText {
+ text: MusicUtils.formatDuration(root.effectiveSeekPosition())
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeXS
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: MusicUtils.formatDuration(mainInstance?.currentDuration || 0)
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeXS
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NButton {
+ text: pluginApi?.tr("panel.saveCurrent")
+ icon: "bookmark-plus"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback && mainInstance?.findSavedEntry({
+ "id": mainInstance?.currentEntryId || "",
+ "url": mainInstance?.currentUrl || ""
+ }) === null
+ onClicked: mainInstance?.saveEntry({
+ "id": mainInstance?.currentEntryId || "",
+ "title": mainInstance?.currentTitle || "",
+ "url": mainInstance?.currentUrl || "",
+ "uploader": mainInstance?.currentUploader || "",
+ "duration": mainInstance?.currentDuration || 0,
+ "provider": mainInstance?.currentProvider || ""
+ })
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.saveCurrentMp3")
+ icon: "download"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback && root.isRemoteEntry({
+ "url": mainInstance?.currentUrl || "",
+ "provider": mainInstance?.currentProvider || ""
+ })
+ onClicked: mainInstance?.downloadCurrentTrack()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.refresh")
+ icon: "refresh"
+ fontSize: Style.fontSizeS
+ onClicked: mainInstance?.refreshStatus(true)
+ }
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: playbackStatusText.visible ? playbackStatusText.implicitHeight : 0
+
+ NText {
+ id: playbackStatusText
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: (mainInstance?.playbackStartingMessage || "").trim()
+ visible: mainInstance?.playbackStarting === true && text.length > 0
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.NoWrap
+ elide: Text.ElideRight
+ }
+ }
+
+ RowLayout {
+ visible: root.hasPlayback && root.showPanelSpeedControls
+ spacing: Math.max(2, Math.round(Style.marginXS * 0.5))
+
+ NButton {
+ text: "-"
+ backgroundColor: "transparent"
+ textColor: Color.mOnSurfaceVariant
+ outlined: false
+ enabled: mainInstance?.isPlaying === true && mainInstance?.speedBusy !== true
+ implicitWidth: Math.round(24 * Style.uiScaleRatio)
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ onClicked: mainInstance?.adjustSpeed(-0.05)
+ }
+
+ Rectangle {
+ radius: Style.radiusM
+ color: Color.mPrimary
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ implicitWidth: Math.max(speedChipLabel.implicitWidth, speedChipWidthReference.implicitWidth) + Math.round(18 * Style.uiScaleRatio)
+ opacity: mainInstance?.speedBusy === true ? 0.75 : 1
+
+ NText {
+ id: speedChipLabel
+ anchors.centerIn: parent
+ text: root.formatSpeed(mainInstance?.currentSpeed || 1)
+ pointSize: Style.fontSizeS
+ color: Color.mOnPrimary
+ }
+
+ NText {
+ id: speedChipWidthReference
+ visible: false
+ text: root.formatSpeed(4)
+ pointSize: Style.fontSizeS
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ enabled: mainInstance?.isPlaying === true && mainInstance?.speedBusy !== true
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: mainInstance?.setSpeed(1)
+ onWheel: wheel => {
+ if (!enabled || wheel.angleDelta.y === 0) {
+ return;
+ }
+
+ mainInstance?.adjustSpeed(wheel.angleDelta.y > 0 ? 0.05 : -0.05);
+ wheel.accepted = true;
+ }
+ }
+ }
+
+ NButton {
+ text: "+"
+ backgroundColor: "transparent"
+ textColor: Color.mOnSurfaceVariant
+ outlined: false
+ enabled: mainInstance?.isPlaying === true && mainInstance?.speedBusy !== true
+ implicitWidth: Math.round(24 * Style.uiScaleRatio)
+ implicitHeight: Math.round(24 * Style.uiScaleRatio)
+ onClicked: mainInstance?.adjustSpeed(0.05)
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ visible: root.showPanelStatusBanner
+ && ((mainInstance?.lastError || "").trim().length > 0 || (mainInstance?.lastNotice || "").trim().length > 0)
+ Layout.fillWidth: true
+ radius: Style.radiusM
+ color: (mainInstance?.lastError || "").trim().length > 0 ? Qt.alpha(Color.mError, 0.14) : Qt.alpha(Color.mPrimary, 0.12)
+ implicitHeight: statusText.implicitHeight + (Style.marginM * 2)
+
+ NText {
+ id: statusText
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ text: (mainInstance?.lastError || "").trim().length > 0 ? (mainInstance?.lastError || "").trim() : (mainInstance?.lastNotice || "").trim()
+ color: (mainInstance?.lastError || "").trim().length > 0 ? Color.mError : Color.mOnSurface
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Style.radiusL
+ color: Color.mSurfaceVariant
+ clip: true
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ NTabBar {
+ id: tabBar
+ Layout.fillWidth: true
+ distributeEvenly: true
+ currentIndex: root.activeTab === "library" ? 1 : (root.activeTab === "queue" ? 2 : 0)
+
+ NTabButton {
+ text: pluginApi?.tr("panel.search")
+ tabIndex: 0
+ checked: tabBar.currentIndex === 0
+ onClicked: root.activeTab = "search"
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.library")
+ tabIndex: 1
+ checked: tabBar.currentIndex === 1
+ onClicked: root.activeTab = "library"
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.queue")
+ tabIndex: 2
+ checked: tabBar.currentIndex === 2
+ onClicked: root.activeTab = "queue"
+ }
+ }
+
+ RowLayout {
+ id: panelTabsRow
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: Style.marginM
+
+ onWidthChanged: root.ensurePreviewPaneWidth(false)
+
+ Item {
+ id: tabViewContainer
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ NTabView {
+ id: tabView
+ anchors.fill: parent
+ currentIndex: tabBar.currentIndex
+
+ Item {
+ height: tabView.height
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: Style.marginM
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NTextInput {
+ id: searchInput
+ Layout.fillWidth: true
+ placeholderText: pluginApi?.tr("panel.searchPlaceholder")
+ text: root.searchText
+
+ onTextChanged: {
+ root.searchText = text;
+ searchDelay.restart();
+ }
+
+ Keys.onReturnPressed: root.performSearch(true)
+ Keys.onEnterPressed: root.performSearch(true)
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.search")
+ icon: "search"
+ fontSize: Style.fontSizeS
+ enabled: !root.searchBusy
+ onClicked: root.performSearch(true)
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+ visible: root.showPanelProviderChips
+
+ ProviderChip { providerKey: "youtube" }
+ ProviderChip { providerKey: "soundcloud" }
+ ProviderChip { providerKey: "local" }
+
+ Item { Layout.fillWidth: true }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: root.panelCardSpacing
+ visible: root.showPanelUrlActions && root.looksLikeUrl(root.trimmedSearchText())
+
+ NButton {
+ text: pluginApi?.tr("panel.playUrl")
+ icon: "player-play-filled"
+ fontSize: Style.fontSizeS
+ onClicked: root.mainInstance?.playUrl(root.trimmedSearchText(), pluginApi?.tr("common.customUrl"))
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.saveUrl")
+ icon: "bookmark-plus"
+ fontSize: Style.fontSizeS
+ onClicked: root.mainInstance?.saveUrl(root.trimmedSearchText())
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.queueUrl")
+ icon: "list"
+ fontSize: Style.fontSizeS
+ onClicked: root.mainInstance?.enqueueUrl(root.trimmedSearchText(), pluginApi?.tr("common.queuedUrl"))
+ }
+ }
+
+ NScrollView {
+ id: searchScroll
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.bottomMargin: Style.marginS
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurfaceVariant
+ bottomPadding: Style.marginS
+
+ ColumnLayout {
+ width: searchScroll.availableWidth
+ spacing: Style.marginM
+
+ Rectangle {
+ visible: root.searchBusy
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Qt.alpha(Color.mPrimary, 0.08)
+ implicitHeight: loadingRow.implicitHeight + (Style.marginL * 2)
+
+ RowLayout {
+ id: loadingRow
+ anchors.centerIn: parent
+ spacing: Style.marginM
+
+ NBusyIndicator {
+ running: root.searchBusy
+ color: Color.mPrimary
+ size: Style.baseWidgetSize * 0.75
+ }
+
+ NText {
+ text: pluginApi?.tr("panel.searching", {"provider": root.providerLabel(root.runningSearchProvider || root.parseSearchProviderQuery(root.trimmedSearchText()).provider)})
+ color: Color.mOnSurface
+ pointSize: Style.fontSizeS
+ }
+ }
+ }
+
+ Rectangle {
+ visible: !root.searchBusy && (root.searchError || "").trim().length > 0
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Qt.alpha(Color.mError, 0.12)
+ implicitHeight: searchErrorText.implicitHeight + (Style.marginL * 2)
+
+ NText {
+ id: searchErrorText
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ text: root.searchError
+ color: Color.mError
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+ }
+
+ Rectangle {
+ visible: !root.searchBusy && !root.looksLikeUrl(root.trimmedSearchText()) && root.trimmedSearchText().length > 0 && root.parseSearchProviderQuery(root.trimmedSearchText()).query.length > 0 && root.parseSearchProviderQuery(root.trimmedSearchText()).query.length < 2
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Qt.alpha(Color.mSurface, 0.6)
+ implicitHeight: shortSearchText.implicitHeight + (Style.marginL * 2)
+
+ NText {
+ id: shortSearchText
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ text: pluginApi?.tr("panel.typeMore", {"provider": root.providerLabel(root.parseSearchProviderQuery(root.trimmedSearchText()).provider)})
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+ }
+
+ Rectangle {
+ visible: root.showPanelSearchHelper
+ && !root.searchBusy
+ && root.trimmedSearchText().length === 0
+ Layout.fillWidth: true
+ radius: Style.radiusL
+ color: Qt.alpha(Color.mSurface, 0.6)
+ implicitHeight: helperColumn.implicitHeight + (Style.marginL * 2)
+
+ ColumnLayout {
+ id: helperColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: Style.marginS
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("panel.searchHint", {"provider": root.providerLabel(mainInstance?.currentProvider || "youtube")})
+ color: Color.mOnSurface
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("panel.helperHint")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeXS
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: root.showPanelRecentTracks
+ && !root.searchBusy
+ && root.trimmedSearchText().length === 0
+ && root.recentLibraryEntries.length > 0
+ text: pluginApi?.tr("panel.recentTracks")
+ color: Color.mOnSurface
+ pointSize: Style.fontSizeM
+ font.weight: Font.DemiBold
+ }
+
+ Repeater {
+ model: root.showPanelRecentTracks
+ && !root.searchBusy
+ && root.trimmedSearchText().length === 0
+ ? root.recentLibraryEntries
+ : []
+
+ delegate: TrackCard {
+ entry: modelData
+ section: "library"
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: !root.searchBusy && root.trimmedSearchText().length > 0 && !root.looksLikeUrl(root.trimmedSearchText()) && root.parseSearchProviderQuery(root.trimmedSearchText()).query.length >= 2 && root.searchResults.length === 0 && (root.searchError || "").trim().length === 0
+ text: pluginApi?.tr("panel.noSearchResults", {"query": root.parseSearchProviderQuery(root.trimmedSearchText()).query})
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ Repeater {
+ model: !root.searchBusy && root.trimmedSearchText().length > 0 && !root.looksLikeUrl(root.trimmedSearchText()) ? root.searchResults : []
+
+ delegate: TrackCard {
+ entry: modelData
+ section: "search"
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ height: tabView.height
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: root.panelSectionSpacing
+
+ NTabBar {
+ id: libraryTabBar
+ Layout.fillWidth: true
+ distributeEvenly: true
+ currentIndex: root.librarySection === "playlists"
+ ? 1
+ : (root.librarySection === "artists"
+ ? 2
+ : (root.librarySection === "tags" ? 3 : 0))
+
+ NTabButton {
+ text: pluginApi?.tr("panel.tracks")
+ tabIndex: 0
+ checked: libraryTabBar.currentIndex === 0
+ onClicked: root.setLibrarySection("tracks")
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.playlists")
+ tabIndex: 1
+ checked: libraryTabBar.currentIndex === 1
+ onClicked: root.setLibrarySection("playlists")
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.artists")
+ tabIndex: 2
+ checked: libraryTabBar.currentIndex === 2
+ onClicked: root.setLibrarySection("artists")
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.tags")
+ tabIndex: 3
+ checked: libraryTabBar.currentIndex === 3
+ onClicked: root.setLibrarySection("tags")
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: root.panelCardSpacing
+
+ NTextInput {
+ id: libraryFilterInput
+ Layout.fillWidth: true
+ placeholderText: root.libraryPlaceholderText()
+ text: root.libraryFilterText
+ onTextChanged: root.libraryFilterText = text
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.playSaved")
+ icon: "player-play-filled"
+ fontSize: Style.fontSizeS
+ visible: !root.hasLibrarySelection() && root.librarySection === "tracks"
+ enabled: (mainInstance?.visibleLibraryEntries() || []).length > 0
+ onClicked: root.mainInstance?.autoplaySavedTracks(false)
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.shuffleSaved")
+ icon: "arrows-shuffle"
+ fontSize: Style.fontSizeS
+ visible: !root.hasLibrarySelection() && root.librarySection === "tracks"
+ enabled: (mainInstance?.visibleLibraryEntries() || []).length > 0
+ onClicked: root.mainInstance?.autoplaySavedTracks(true)
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.backAction")
+ icon: "arrow-left"
+ fontSize: Style.fontSizeS
+ visible: root.hasLibrarySelection()
+ onClicked: root.clearLibrarySelection()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.playAction")
+ icon: "player-play-filled"
+ fontSize: Style.fontSizeS
+ visible: root.hasLibrarySelection()
+ enabled: root.activeLibrarySelectionList.length > 0
+ onClicked: root.playCurrentLibrarySelection(false)
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.shuffleAction")
+ icon: "arrows-shuffle"
+ fontSize: Style.fontSizeS
+ visible: root.hasLibrarySelection()
+ enabled: root.activeLibrarySelectionList.length > 0
+ onClicked: root.playCurrentLibrarySelection(true)
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.queueAction")
+ icon: "list"
+ fontSize: Style.fontSizeS
+ visible: root.hasLibrarySelection()
+ enabled: root.activeLibrarySelectionList.length > 0
+ onClicked: root.queueCurrentLibrarySelection(false)
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: {
+ if (root.hasLibrarySelection()) {
+ return root.activeLibrarySelectionTitle().length > 0
+ ? (root.activeLibrarySelectionTitle() + " • " + pluginApi?.tr("playlists.trackCount", {"count": root.activeLibrarySelectionList.length}))
+ : pluginApi?.tr("playlists.trackCount", {"count": root.activeLibrarySelectionList.length});
+ }
+ if (root.librarySection === "playlists") {
+ return pluginApi?.tr("panel.playlistCount", {"count": root.filteredPlaylistEntries.length});
+ }
+ if (root.librarySection === "artists") {
+ return pluginApi?.tr("panel.artistCount", {"count": root.filteredArtistStats.length});
+ }
+ if (root.librarySection === "tags") {
+ return pluginApi?.tr("panel.tagCount", {"count": root.filteredTagStats.length});
+ }
+ return pluginApi?.tr("panel.savedCount", {"count": root.filteredLibraryEntries.length});
+ }
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.bottomMargin: Style.marginS
+
+ NText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: (root.hasLibrarySelection() || root.librarySection === "tracks")
+ && root.activeLibraryTrackList.length === 0
+ text: root.hasLibrarySelection()
+ ? pluginApi?.tr("panel.emptyScopedLibrary")
+ : pluginApi?.tr("panel.emptyLibrary")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NListView {
+ id: libraryTrackList
+ anchors.fill: parent
+ visible: (root.hasLibrarySelection() || root.librarySection === "tracks")
+ && root.activeLibraryTrackList.length > 0
+ spacing: Style.marginM
+ cacheBuffer: Math.round(1000 * Style.uiScaleRatio)
+ boundsBehavior: Flickable.StopAtBounds
+ model: root.activeLibraryTrackList
+ verticalPolicy: ScrollBar.AsNeeded
+ horizontalPolicy: ScrollBar.AlwaysOff
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurfaceVariant
+
+ delegate: Item {
+ width: libraryTrackList.availableWidth
+ implicitHeight: libraryTrackCard.implicitHeight
+
+ TrackCard {
+ id: libraryTrackCard
+ anchors.left: parent.left
+ anchors.right: parent.right
+ entry: modelData
+ section: "library"
+ }
+ }
+ }
+
+ NText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: !root.hasLibrarySelection() && root.librarySection === "playlists" && root.filteredPlaylistEntries.length === 0
+ text: pluginApi?.tr("panel.emptyPlaylists")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: !root.hasLibrarySelection() && root.librarySection === "artists" && root.filteredArtistStats.length === 0
+ text: pluginApi?.tr("panel.emptyArtists")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: !root.hasLibrarySelection() && root.librarySection === "tags" && root.filteredTagStats.length === 0
+ text: pluginApi?.tr("panel.emptyTags")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NScrollView {
+ id: libraryBrowseScroll
+ anchors.fill: parent
+ visible: !root.hasLibrarySelection()
+ && root.librarySection !== "tracks"
+ && ((root.librarySection === "playlists" && root.filteredPlaylistEntries.length > 0)
+ || (root.librarySection === "artists" && root.filteredArtistStats.length > 0)
+ || (root.librarySection === "tags" && root.filteredTagStats.length > 0))
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurfaceVariant
+ bottomPadding: Style.marginS
+
+ ColumnLayout {
+ width: libraryBrowseScroll.availableWidth
+ spacing: Style.marginM
+
+ Repeater {
+ model: root.librarySection === "playlists" ? root.filteredPlaylistEntries : []
+
+ delegate: LibraryBrowseCard {
+ title: modelData?.name || pluginApi?.tr("playlists.untitled")
+ description: {
+ var parts = [pluginApi?.tr("playlists.trackCount", {"count": root.playlistEntryCount(modelData)})];
+ var sourceFolder = String(modelData?.sourceFolder || "").trim();
+ if (sourceFolder.length > 0) {
+ parts.push(sourceFolder);
+ }
+ return parts.join(" • ");
+ }
+ accentText: String(root.playlistEntryCount(modelData))
+ iconText: "\u266b"
+ primaryAction: ({
+ "text": pluginApi?.tr("panel.openAction"),
+ "icon": "folder-open",
+ "onClicked": function () {
+ root.openPlaylistSection(modelData?.id || "");
+ }
+ })
+ secondaryAction: ({
+ "text": pluginApi?.tr("panel.playAction"),
+ "icon": "player-play-filled",
+ "onClicked": function () {
+ root.mainInstance?.playPlaylist(modelData?.id || "", false);
+ }
+ })
+ tertiaryAction: ({
+ "text": pluginApi?.tr("panel.shuffleAction"),
+ "icon": "arrows-shuffle",
+ "onClicked": function () {
+ root.mainInstance?.playPlaylist(modelData?.id || "", true);
+ }
+ })
+ quaternaryAction: ({
+ "text": pluginApi?.tr("panel.queueAction"),
+ "icon": "list",
+ "onClicked": function () {
+ root.mainInstance?.queuePlaylist(modelData?.id || "", false);
+ }
+ })
+ }
+ }
+
+ Repeater {
+ model: root.librarySection === "artists" ? root.filteredArtistStats : []
+
+ delegate: LibraryBrowseCard {
+ title: modelData?.name || ""
+ description: {
+ var parts = [pluginApi?.tr("library.trackCount", {"count": Number(modelData?.count || 0)})];
+ var plays = root.formatPlayCount(Number(modelData?.playCount || 0));
+ if (plays.length > 0) {
+ parts.push(plays);
+ }
+ return parts.join(" • ");
+ }
+ accentText: String(Number(modelData?.count || 0))
+ iconText: "\u25c9"
+ primaryAction: ({
+ "text": pluginApi?.tr("panel.openAction"),
+ "icon": "folder-open",
+ "onClicked": function () {
+ root.openArtistSection(modelData?.name || "");
+ }
+ })
+ }
+ }
+
+ Repeater {
+ model: root.librarySection === "tags" ? root.filteredTagStats : []
+
+ delegate: LibraryBrowseCard {
+ title: "#" + (modelData?.tag || "")
+ description: pluginApi?.tr("library.trackCount", {"count": Number(modelData?.count || 0)})
+ accentText: String(Number(modelData?.count || 0))
+ iconText: "#"
+ primaryAction: ({
+ "text": pluginApi?.tr("panel.openAction"),
+ "icon": "folder-open",
+ "onClicked": function () {
+ root.openTagSection(modelData?.tag || "");
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ height: tabView.height
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: Style.marginM
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: root.panelCardSpacing
+ visible: root.showPanelQueueControls
+
+ NButton {
+ text: pluginApi?.tr("panel.startQueue")
+ icon: "player-play-filled"
+ fontSize: Style.fontSizeS
+ enabled: (mainInstance?.queueEntries || []).length > 0
+ onClicked: root.mainInstance?.startQueue()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.skipQueue")
+ icon: "player-skip-forward"
+ fontSize: Style.fontSizeS
+ enabled: (mainInstance?.queueEntries || []).length > 0
+ onClicked: root.mainInstance?.skipQueue()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.clearQueue")
+ icon: "trash"
+ fontSize: Style.fontSizeS
+ enabled: (mainInstance?.queueEntries || []).length > 0
+ onClicked: root.mainInstance?.clearQueue()
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("panel.queueCount", {"count": (mainInstance?.queueEntries || []).length})
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.bottomMargin: Style.marginS
+
+ NText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: (mainInstance?.queueEntries || []).length === 0
+ text: pluginApi?.tr("panel.emptyQueue")
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.Wrap
+ }
+
+ NListView {
+ id: queueList
+ anchors.fill: parent
+ visible: (mainInstance?.queueEntries || []).length > 0
+ spacing: Style.marginM
+ cacheBuffer: Math.round(800 * Style.uiScaleRatio)
+ boundsBehavior: Flickable.StopAtBounds
+ model: mainInstance?.queueEntries || []
+ verticalPolicy: ScrollBar.AsNeeded
+ horizontalPolicy: ScrollBar.AlwaysOff
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurfaceVariant
+
+ delegate: Item {
+ width: queueList.availableWidth
+ implicitHeight: queueCard.implicitHeight
+
+ TrackCard {
+ id: queueCard
+ anchors.left: parent.left
+ anchors.right: parent.right
+ entry: modelData
+ section: "queue"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ NIconButton {
+ visible: root.showPanelPreview && !root.panelPreviewItem && root.hasPlayback
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: -Math.round(baseSize * 0.35)
+ z: 2
+ icon: "chevron-left"
+ tooltipText: pluginApi?.tr("panel.showPreview")
+ baseSize: 26
+ onClicked: root.syncPanelPlaybackPreview(true)
+ }
+ }
+
+ Item {
+ visible: root.showPanelPreview && !!root.panelPreviewItem
+ Layout.preferredWidth: Math.round(10 * Style.uiScaleRatio)
+ Layout.fillHeight: true
+
+ Rectangle {
+ width: Math.max(2, Math.round(3 * Style.uiScaleRatio))
+ radius: width / 2
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: Style.marginM
+ anchors.bottomMargin: Style.marginM
+ color: splitterMouseArea.pressed
+ ? Qt.alpha(Color.mPrimary, 0.65)
+ : (splitterMouseArea.containsMouse
+ ? Qt.alpha(Color.mPrimary, 0.38)
+ : Qt.alpha((Color.mOutline || Color.mOnSurfaceVariant || "#888888"), 0.28))
+ }
+
+ MouseArea {
+ id: splitterMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.SizeHorCursor
+
+ property real dragStartX: 0
+ property real dragStartWidth: 0
+
+ onPressed: mouse => {
+ dragStartX = splitterMouseArea.mapToItem(panelTabsRow, mouse.x, mouse.y).x;
+ dragStartWidth = root.previewPaneWidth;
+ }
+
+ onPositionChanged: mouse => {
+ if (!pressed) {
+ return;
+ }
+ var currentX = splitterMouseArea.mapToItem(panelTabsRow, mouse.x, mouse.y).x;
+ root.persistPreviewPaneWidth(dragStartWidth - (currentX - dragStartX), false);
+ }
+
+ onReleased: root.persistPreviewPaneWidth(root.previewPaneWidth, true)
+ }
+ }
+
+ Rectangle {
+ id: previewPane
+ visible: root.showPanelPreview && !!root.panelPreviewItem
+ Layout.preferredWidth: root.previewPaneWidth > 0
+ ? root.previewPaneWidth
+ : root.clampPreviewPaneWidth(Math.round(panelTabsRow.width * 0.34))
+ Layout.maximumWidth: root.previewPaneMaxWidthCap
+ Layout.fillHeight: true
+ radius: root.panelCardRadius
+ color: Qt.alpha(Color.mSurface, 0.55)
+ border.width: Style.borderS
+ border.color: Qt.alpha((Color.mOutline || Color.mOnSurfaceVariant || "#888888"), 0.24)
+ clip: true
+ readonly property bool showPreviewPlaybackActions: !root.showPanelNowPlaying
+ && root.hasPlayback
+ && root.previewItemsEqual(root.panelPreviewItem, root.playbackPreviewEntry())
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginS
+
+ RowLayout {
+ id: previewHeader
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NIconButton {
+ icon: "x"
+ tooltipText: pluginApi?.tr("panel.hidePreview")
+ baseSize: 28
+ onClicked: root.clearPanelPreview()
+ }
+ }
+
+ MusicPreview {
+ id: panelPreview
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentItem: root.panelPreviewItem
+ showChips: false
+ showLengthDetails: false
+ showPlaybackProgress: !(root.showPanelNowPlaying && root.showPanelPlaybackProgress)
+ showInlineSpeedControls: previewPane.showPreviewPlaybackActions
+ }
+
+ Flow {
+ Layout.fillWidth: true
+ visible: previewPane.showPreviewPlaybackActions
+ width: parent.width
+ spacing: Style.marginS
+
+ NButton {
+ text: mainInstance?.isPaused === true ? pluginApi?.tr("panel.resume") : pluginApi?.tr("panel.pause")
+ icon: mainInstance?.isPaused === true ? "player-play-filled" : "player-pause-filled"
+ fontSize: Style.fontSizeS
+ enabled: mainInstance?.isPlaying === true
+ onClicked: mainInstance?.togglePause()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.stop")
+ icon: "player-stop-filled"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback
+ onClicked: mainInstance?.stopPlayback()
+ }
+ }
+
+ NText {
+ id: previewPlaybackStatus
+ Layout.fillWidth: true
+ visible: previewPane.showPreviewPlaybackActions
+ && mainInstance?.playbackStarting === true
+ && text.length > 0
+ text: (mainInstance?.playbackStartingMessage || "").trim()
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.NoWrap
+ elide: Text.ElideRight
+ }
+
+ Flow {
+ Layout.fillWidth: true
+ visible: previewPane.showPreviewPlaybackActions
+ width: parent.width
+ spacing: Style.marginS
+
+ NButton {
+ text: pluginApi?.tr("panel.saveCurrent")
+ icon: "bookmark-plus"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback && mainInstance?.findSavedEntry({
+ "id": mainInstance?.currentEntryId || "",
+ "url": mainInstance?.currentUrl || ""
+ }) === null
+ onClicked: mainInstance?.saveEntry({
+ "id": mainInstance?.currentEntryId || "",
+ "title": mainInstance?.currentTitle || "",
+ "url": mainInstance?.currentUrl || "",
+ "uploader": mainInstance?.currentUploader || "",
+ "duration": mainInstance?.currentDuration || 0,
+ "provider": mainInstance?.currentProvider || ""
+ })
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.saveCurrentMp3")
+ icon: "download"
+ fontSize: Style.fontSizeS
+ enabled: root.hasPlayback && root.isRemoteEntry({
+ "url": mainInstance?.currentUrl || "",
+ "provider": mainInstance?.currentProvider || ""
+ })
+ onClicked: mainInstance?.downloadCurrentTrack()
+ }
+
+ NButton {
+ text: pluginApi?.tr("panel.refresh")
+ icon: "refresh"
+ fontSize: Style.fontSizeS
+ onClicked: mainInstance?.refreshStatus(true)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/README.md b/.config/noctalia/plugins/music-search/README.md
new file mode 100644
index 0000000..3ca84b9
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/README.md
@@ -0,0 +1,56 @@
+# music-search Plugin
+
+Search YouTube, SoundCloud, or local files, play audio in the background with `mpv`, and manage a saved library with playlists, tags, ratings, preview metadata, and a built-in queue.
+
+## Bar and panel usage
+
+- Add the Music Search bar widget to open a dedicated panel in one click
+- The panel exposes search, playback controls, saved tracks, and queue management without going through the launcher
+- The launcher provider still exposes the full command surface when you want advanced flows like `playlist:`, `tag:`, or `edit:`
+
+## Requirements
+
+- `mpv` for playback
+- `yt-dlp` for YouTube and SoundCloud search/details/downloads
+- `jq` for local persistence helpers
+- `ffprobe` is optional but improves local metadata detection
+
+## Launcher usage
+
+- `>music-search` opens the home view with status, library shortcuts, recent plays, top tracks, tags, artists, and playlists
+- `>music-search ` searches the active provider
+- `>music-search yt:`, `sc:`, `local:` force a provider
+- `>music-search ` plays a direct URL immediately
+- `>music-search saved:` browses the saved library
+- `>music-search queue` opens the built-in queue browser and controls
+- `>music-search playlist:` browses, creates, renames, and launches playlists
+- `>music-search artist:` browses saved tracks by uploader
+- `>music-search #tag` or `>music-search tag:` filters or edits tags
+- `>music-search edit:` updates title, artist, or album metadata for saved entries
+- `>music-search speed:1.05` adjusts playback speed
+- `>music-search stop` stops background playback
+
+Search results and saved tracks expose inline actions for queueing, saving, downloading, metadata edits, tags, and playlists. Queue controls are now part of the music-search plugin, so the old standalone `queue` plugin should be treated as legacy.
+
+The internal plugin ID remains `music` for IPC compatibility.
+
+## IPC usage
+
+```bash
+qs -c noctalia-shell ipc call plugin:music launcher
+qs -c noctalia-shell ipc call plugin:music panel
+qs -c noctalia-shell ipc call plugin:music play "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+qs -c noctalia-shell ipc call plugin:music save "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+qs -c noctalia-shell ipc call plugin:music seek 90
+qs -c noctalia-shell ipc call plugin:music stop
+```
+
+## Data files
+
+- By default, runtime data lives under `~/.cache/noctalia/plugins/music-search/`
+- `~/.cache/noctalia/plugins/music-search/library.json` stores saved tracks and playback stats
+- `~/.cache/noctalia/plugins/music-search/playlists.json` stores playlists
+- `~/.cache/noctalia/plugins/music-search/queue.json` stores the built-in persistent queue
+- `~/.cache/noctalia/plugins/music-search/state.json` stores current playback state
+- `~/.cache/noctalia/plugins/music-search/settings.json` stores local user state
+- Set `MUSIC_CACHE_DIR` to override the default cache directory
diff --git a/.config/noctalia/plugins/music-search/Settings.qml b/.config/noctalia/plugins/music-search/Settings.qml
new file mode 100644
index 0000000..f797d8d
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/Settings.qml
@@ -0,0 +1,613 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+
+ property var pluginApi: null
+ readonly property var mainInstance: pluginApi?.mainInstance ?? null
+ readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings ?? ({})
+ readonly property string defaultDownloadDirectory: Quickshell.env("HOME") + "/Music/Noctalia"
+ readonly property string currentProvider: root.mainInstance?.currentProvider ?? "youtube"
+ readonly property string currentSortBy: root.mainInstance?.currentSortBy ?? "date"
+ readonly property string currentYtPlayerClient: root.mainInstance?.ytPlayerClient ?? "android"
+ readonly property string currentDownloadDirectory: root.mainInstance?.downloadDirectory ?? root.defaultDownloadDirectory
+ readonly property int currentDownloadCacheMaxMb: root.mainInstance?.downloadCacheMaxMb ?? 0
+ readonly property string previewMetadataMode: pluginApi?.pluginSettings?.previewMetadataMode
+ ?? root.defaults.previewMetadataMode
+ ?? "always"
+ readonly property bool showUploaderMetadata: pluginApi?.pluginSettings?.showUploaderMetadata
+ ?? root.defaults.showUploaderMetadata
+ ?? true
+ readonly property bool showAlbumMetadata: pluginApi?.pluginSettings?.showAlbumMetadata
+ ?? root.defaults.showAlbumMetadata
+ ?? true
+ readonly property bool showDurationMetadata: pluginApi?.pluginSettings?.showDurationMetadata
+ ?? root.defaults.showDurationMetadata
+ ?? true
+ readonly property bool showRatingMetadata: pluginApi?.pluginSettings?.showRatingMetadata
+ ?? root.defaults.showRatingMetadata
+ ?? true
+ readonly property bool showTagMetadata: pluginApi?.pluginSettings?.showTagMetadata
+ ?? root.defaults.showTagMetadata
+ ?? true
+ readonly property bool showPlayStatsMetadata: pluginApi?.pluginSettings?.showPlayStatsMetadata
+ ?? root.defaults.showPlayStatsMetadata
+ ?? true
+ readonly property bool showStatusMetadata: pluginApi?.pluginSettings?.showStatusMetadata
+ ?? root.defaults.showStatusMetadata
+ ?? true
+ readonly property bool showPreviewChips: pluginApi?.pluginSettings?.showPreviewChips
+ ?? root.defaults.showPreviewChips
+ ?? true
+ readonly property string previewThumbnailSize: pluginApi?.pluginSettings?.previewThumbnailSize
+ ?? root.defaults.previewThumbnailSize
+ ?? "comfortable"
+ readonly property bool showHomeRecent: pluginApi?.pluginSettings?.showHomeRecent
+ ?? root.defaults.showHomeRecent
+ ?? true
+ readonly property bool showHomeTop: pluginApi?.pluginSettings?.showHomeTop
+ ?? root.defaults.showHomeTop
+ ?? true
+ readonly property bool showHomeTags: pluginApi?.pluginSettings?.showHomeTags
+ ?? root.defaults.showHomeTags
+ ?? true
+ readonly property bool showHomeArtists: pluginApi?.pluginSettings?.showHomeArtists
+ ?? root.defaults.showHomeArtists
+ ?? true
+ readonly property bool showHomePlaylists: pluginApi?.pluginSettings?.showHomePlaylists
+ ?? root.defaults.showHomePlaylists
+ ?? true
+ readonly property string defaultPanelTab: pluginApi?.pluginSettings?.defaultPanelTab
+ ?? root.defaults.defaultPanelTab
+ ?? "search"
+ readonly property string defaultPanelLibrarySection: pluginApi?.pluginSettings?.defaultPanelLibrarySection
+ ?? root.defaults.defaultPanelLibrarySection
+ ?? "tracks"
+ readonly property string panelDensity: pluginApi?.pluginSettings?.panelDensity
+ ?? root.defaults.panelDensity
+ ?? "balanced"
+ readonly property bool showPanelHeader: pluginApi?.pluginSettings?.showPanelHeader
+ ?? root.defaults.showPanelHeader
+ ?? true
+ readonly property bool showPanelNowPlaying: pluginApi?.pluginSettings?.showPanelNowPlaying
+ ?? root.defaults.showPanelNowPlaying
+ ?? true
+ readonly property bool showPanelPlaybackProgress: pluginApi?.pluginSettings?.showPanelPlaybackProgress
+ ?? root.defaults.showPanelPlaybackProgress
+ ?? true
+ readonly property bool showPanelProviderChips: pluginApi?.pluginSettings?.showPanelProviderChips
+ ?? root.defaults.showPanelProviderChips
+ ?? true
+ readonly property bool showPanelRecentTracks: pluginApi?.pluginSettings?.showPanelRecentTracks
+ ?? root.defaults.showPanelRecentTracks
+ ?? true
+ readonly property bool showPanelSearchHelper: pluginApi?.pluginSettings?.showPanelSearchHelper
+ ?? root.defaults.showPanelSearchHelper
+ ?? true
+ readonly property bool showPanelPreview: pluginApi?.pluginSettings?.showPanelPreview
+ ?? root.defaults.showPanelPreview
+ ?? true
+ readonly property bool showPanelUrlActions: pluginApi?.pluginSettings?.showPanelUrlActions
+ ?? root.defaults.showPanelUrlActions
+ ?? true
+ readonly property bool showPanelSpeedControls: pluginApi?.pluginSettings?.showPanelSpeedControls
+ ?? root.defaults.showPanelSpeedControls
+ ?? true
+ readonly property bool showPanelQueueControls: pluginApi?.pluginSettings?.showPanelQueueControls
+ ?? root.defaults.showPanelQueueControls
+ ?? true
+ readonly property bool showPanelStatusBanner: pluginApi?.pluginSettings?.showPanelStatusBanner
+ ?? root.defaults.showPanelStatusBanner
+ ?? true
+ readonly property bool showBarHoverTrackTitle: pluginApi?.pluginSettings?.showBarHoverTrackTitle
+ ?? root.defaults.showBarHoverTrackTitle
+ ?? true
+ readonly property bool autoSaveMp3AfterPlayback: pluginApi?.pluginSettings?.autoSaveMp3AfterPlayback
+ ?? root.defaults.autoSaveMp3AfterPlayback
+ ?? false
+ property string editDownloadDirectory: root.currentDownloadDirectory
+ property int editDownloadCacheMaxMb: root.currentDownloadCacheMaxMb
+
+ spacing: Style.marginL
+
+ function saveSetting(key, value) {
+ if (!pluginApi) {
+ Logger.e("MusicSearch", "Cannot save settings: pluginApi is null");
+ return;
+ }
+
+ pluginApi.pluginSettings[key] = value;
+ pluginApi.saveSettings();
+ }
+
+ function applyDownloadDirectory() {
+ var target = (editDownloadDirectory || "").trim();
+ if (target.length === 0) {
+ return;
+ }
+ pluginApi?.mainInstance?.setDownloadDirectory(target);
+ }
+
+ function applyCacheLimit() {
+ var target = Math.max(0, Math.floor(editDownloadCacheMaxMb || 0));
+ editDownloadCacheMaxMb = target;
+ pluginApi?.mainInstance?.setDownloadCacheMaxMb(target);
+ }
+
+ onCurrentDownloadDirectoryChanged: root.editDownloadDirectory = root.currentDownloadDirectory
+ onCurrentDownloadCacheMaxMbChanged: root.editDownloadCacheMaxMb = root.currentDownloadCacheMaxMb
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.provider.label")
+ description: pluginApi?.tr("settings.provider.desc")
+ model: [
+ {"key": "youtube", "name": pluginApi?.tr("providers.youtube")},
+ {"key": "soundcloud", "name": pluginApi?.tr("providers.soundcloud")},
+ {"key": "local", "name": pluginApi?.tr("providers.local")}
+ ]
+ currentKey: root.currentProvider
+ defaultValue: "youtube"
+ onSelected: key => pluginApi?.mainInstance?.setProvider(key)
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.sort.label")
+ description: pluginApi?.tr("settings.sort.desc")
+ model: [
+ {"key": "date", "name": pluginApi?.tr("sort.savedDate")},
+ {"key": "title", "name": pluginApi?.tr("sort.title")},
+ {"key": "duration", "name": pluginApi?.tr("sort.duration")},
+ {"key": "rating", "name": pluginApi?.tr("sort.rating")}
+ ]
+ currentKey: root.currentSortBy
+ defaultValue: "date"
+ onSelected: key => pluginApi?.mainInstance?.setSortBy(key)
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.ytClient.label")
+ description: pluginApi?.tr("settings.ytClient.desc")
+ model: [
+ {"key": "android", "name": pluginApi?.tr("settings.ytClient.android")},
+ {"key": "web", "name": pluginApi?.tr("settings.ytClient.web")},
+ {"key": "default", "name": pluginApi?.tr("settings.ytClient.default")}
+ ]
+ currentKey: root.currentYtPlayerClient
+ defaultValue: "android"
+ onSelected: key => pluginApi?.mainInstance?.setYtPlayerClient(key)
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.downloads.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.downloads.currentFolder", {"path": root.editDownloadDirectory})
+ wrapMode: Text.Wrap
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NButton {
+ text: pluginApi?.tr("settings.downloads.chooseFolder")
+ onClicked: downloadFolderPicker.open()
+ }
+
+ NButton {
+ text: pluginApi?.tr("settings.downloads.applyFolder")
+ enabled: (root.editDownloadDirectory || "").trim().length > 0
+ onClicked: root.applyDownloadDirectory()
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NLabel {
+ label: pluginApi?.tr("settings.cache.label")
+ description: pluginApi?.tr("settings.cache.desc")
+ }
+
+ NSpinBox {
+ from: 0
+ to: 500000
+ stepSize: 128
+ value: root.editDownloadCacheMaxMb
+ onValueChanged: if (value !== root.editDownloadCacheMaxMb) root.editDownloadCacheMaxMb = value
+ }
+
+ NButton {
+ text: pluginApi?.tr("settings.cache.apply")
+ onClicked: root.applyCacheLimit()
+ }
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.autoSave.label")
+ description: pluginApi?.tr("settings.autoSave.desc")
+ checked: root.autoSaveMp3AfterPlayback
+ onToggled: root.saveSetting("autoSaveMp3AfterPlayback", checked)
+ defaultValue: root.defaults.autoSaveMp3AfterPlayback ?? false
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.home.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.home.recent.label")
+ description: pluginApi?.tr("settings.home.recent.desc")
+ checked: root.showHomeRecent
+ onToggled: root.saveSetting("showHomeRecent", checked)
+ defaultValue: root.defaults.showHomeRecent ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.home.top.label")
+ description: pluginApi?.tr("settings.home.top.desc")
+ checked: root.showHomeTop
+ onToggled: root.saveSetting("showHomeTop", checked)
+ defaultValue: root.defaults.showHomeTop ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.home.tags.label")
+ description: pluginApi?.tr("settings.home.tags.desc")
+ checked: root.showHomeTags
+ onToggled: root.saveSetting("showHomeTags", checked)
+ defaultValue: root.defaults.showHomeTags ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.home.artists.label")
+ description: pluginApi?.tr("settings.home.artists.desc")
+ checked: root.showHomeArtists
+ onToggled: root.saveSetting("showHomeArtists", checked)
+ defaultValue: root.defaults.showHomeArtists ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.home.playlists.label")
+ description: pluginApi?.tr("settings.home.playlists.desc")
+ checked: root.showHomePlaylists
+ onToggled: root.saveSetting("showHomePlaylists", checked)
+ defaultValue: root.defaults.showHomePlaylists ?? true
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.panel.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.panel.defaultTab.label")
+ description: pluginApi?.tr("settings.panel.defaultTab.desc")
+ model: [
+ {"key": "search", "name": pluginApi?.tr("panel.search")},
+ {"key": "library", "name": pluginApi?.tr("panel.library")},
+ {"key": "queue", "name": pluginApi?.tr("panel.queue")}
+ ]
+ currentKey: root.defaultPanelTab
+ defaultValue: root.defaults.defaultPanelTab ?? "search"
+ onSelected: key => root.saveSetting("defaultPanelTab", key)
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.panel.defaultLibrarySection.label")
+ description: pluginApi?.tr("settings.panel.defaultLibrarySection.desc")
+ model: [
+ {"key": "tracks", "name": pluginApi?.tr("panel.tracks")},
+ {"key": "playlists", "name": pluginApi?.tr("panel.playlists")},
+ {"key": "artists", "name": pluginApi?.tr("panel.artists")},
+ {"key": "tags", "name": pluginApi?.tr("panel.tags")}
+ ]
+ currentKey: root.defaultPanelLibrarySection
+ defaultValue: root.defaults.defaultPanelLibrarySection ?? "tracks"
+ onSelected: key => root.saveSetting("defaultPanelLibrarySection", key)
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.panel.density.label")
+ description: pluginApi?.tr("settings.panel.density.desc")
+ model: [
+ {"key": "compact", "name": pluginApi?.tr("settings.panel.density.compact")},
+ {"key": "balanced", "name": pluginApi?.tr("settings.panel.density.balanced")},
+ {"key": "roomy", "name": pluginApi?.tr("settings.panel.density.roomy")}
+ ]
+ currentKey: root.panelDensity
+ defaultValue: root.defaults.panelDensity ?? "balanced"
+ onSelected: key => root.saveSetting("panelDensity", key)
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.header.label")
+ description: pluginApi?.tr("settings.panel.header.desc")
+ checked: root.showPanelHeader
+ onToggled: root.saveSetting("showPanelHeader", checked)
+ defaultValue: root.defaults.showPanelHeader ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.nowPlaying.label")
+ description: pluginApi?.tr("settings.panel.nowPlaying.desc")
+ checked: root.showPanelNowPlaying
+ onToggled: root.saveSetting("showPanelNowPlaying", checked)
+ defaultValue: root.defaults.showPanelNowPlaying ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.playbackProgress.label")
+ description: pluginApi?.tr("settings.panel.playbackProgress.desc")
+ checked: root.showPanelPlaybackProgress
+ onToggled: root.saveSetting("showPanelPlaybackProgress", checked)
+ defaultValue: root.defaults.showPanelPlaybackProgress ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.providerChips.label")
+ description: pluginApi?.tr("settings.panel.providerChips.desc")
+ checked: root.showPanelProviderChips
+ onToggled: root.saveSetting("showPanelProviderChips", checked)
+ defaultValue: root.defaults.showPanelProviderChips ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.recentTracks.label")
+ description: pluginApi?.tr("settings.panel.recentTracks.desc")
+ checked: root.showPanelRecentTracks
+ onToggled: root.saveSetting("showPanelRecentTracks", checked)
+ defaultValue: root.defaults.showPanelRecentTracks ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.searchHelper.label")
+ description: pluginApi?.tr("settings.panel.searchHelper.desc")
+ checked: root.showPanelSearchHelper
+ onToggled: root.saveSetting("showPanelSearchHelper", checked)
+ defaultValue: root.defaults.showPanelSearchHelper ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.preview.label")
+ description: pluginApi?.tr("settings.panel.preview.desc")
+ checked: root.showPanelPreview
+ onToggled: root.saveSetting("showPanelPreview", checked)
+ defaultValue: root.defaults.showPanelPreview ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.urlActions.label")
+ description: pluginApi?.tr("settings.panel.urlActions.desc")
+ checked: root.showPanelUrlActions
+ onToggled: root.saveSetting("showPanelUrlActions", checked)
+ defaultValue: root.defaults.showPanelUrlActions ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.speedControls.label")
+ description: pluginApi?.tr("settings.panel.speedControls.desc")
+ checked: root.showPanelSpeedControls
+ onToggled: root.saveSetting("showPanelSpeedControls", checked)
+ defaultValue: root.defaults.showPanelSpeedControls ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.queueControls.label")
+ description: pluginApi?.tr("settings.panel.queueControls.desc")
+ checked: root.showPanelQueueControls
+ onToggled: root.saveSetting("showPanelQueueControls", checked)
+ defaultValue: root.defaults.showPanelQueueControls ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.panel.statusBanner.label")
+ description: pluginApi?.tr("settings.panel.statusBanner.desc")
+ checked: root.showPanelStatusBanner
+ onToggled: root.saveSetting("showPanelStatusBanner", checked)
+ defaultValue: root.defaults.showPanelStatusBanner ?? true
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.bar.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.bar.hoverTitle.label")
+ description: pluginApi?.tr("settings.bar.hoverTitle.desc")
+ checked: root.showBarHoverTrackTitle
+ onToggled: root.saveSetting("showBarHoverTrackTitle", checked)
+ defaultValue: root.defaults.showBarHoverTrackTitle ?? true
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.preview.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.preview.metadata.label")
+ description: pluginApi?.tr("settings.preview.metadata.desc")
+ model: [
+ {"key": "always", "name": pluginApi?.tr("settings.preview.metadata.all")},
+ {"key": "playing", "name": pluginApi?.tr("settings.preview.metadata.playing")},
+ {"key": "never", "name": pluginApi?.tr("settings.preview.metadata.disabled")}
+ ]
+ currentKey: root.previewMetadataMode
+ defaultValue: root.defaults.previewMetadataMode ?? "always"
+ onSelected: key => root.saveSetting("previewMetadataMode", key)
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: {
+ if (root.previewMetadataMode === "never") {
+ return pluginApi?.tr("settings.preview.metadata.neverHint");
+ }
+ if (root.previewMetadataMode === "playing") {
+ return pluginApi?.tr("settings.preview.metadata.playingHint");
+ }
+ return pluginApi?.tr("settings.preview.metadata.alwaysHint");
+ }
+ wrapMode: Text.Wrap
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ }
+
+ NComboBox {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.preview.thumbnail.label")
+ description: pluginApi?.tr("settings.preview.thumbnail.desc")
+ model: [
+ {"key": "small", "name": pluginApi?.tr("settings.preview.thumbnail.small")},
+ {"key": "comfortable", "name": pluginApi?.tr("settings.preview.thumbnail.comfortable")},
+ {"key": "large", "name": pluginApi?.tr("settings.preview.thumbnail.large")}
+ ]
+ currentKey: root.previewThumbnailSize
+ defaultValue: root.defaults.previewThumbnailSize ?? "comfortable"
+ onSelected: key => root.saveSetting("previewThumbnailSize", key)
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.preview.chips.label")
+ description: pluginApi?.tr("settings.preview.chips.desc")
+ checked: root.showPreviewChips
+ onToggled: root.saveSetting("showPreviewChips", checked)
+ defaultValue: root.defaults.showPreviewChips ?? true
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("settings.metadata.title")
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.uploader.label")
+ description: pluginApi?.tr("settings.metadata.uploader.desc")
+ checked: root.showUploaderMetadata
+ onToggled: root.saveSetting("showUploaderMetadata", checked)
+ defaultValue: root.defaults.showUploaderMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.album.label")
+ description: pluginApi?.tr("settings.metadata.album.desc")
+ checked: root.showAlbumMetadata
+ onToggled: root.saveSetting("showAlbumMetadata", checked)
+ defaultValue: root.defaults.showAlbumMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.duration.label")
+ description: pluginApi?.tr("settings.metadata.duration.desc")
+ checked: root.showDurationMetadata
+ onToggled: root.saveSetting("showDurationMetadata", checked)
+ defaultValue: root.defaults.showDurationMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.rating.label")
+ description: pluginApi?.tr("settings.metadata.rating.desc")
+ checked: root.showRatingMetadata
+ onToggled: root.saveSetting("showRatingMetadata", checked)
+ defaultValue: root.defaults.showRatingMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.tags.label")
+ description: pluginApi?.tr("settings.metadata.tags.desc")
+ checked: root.showTagMetadata
+ onToggled: root.saveSetting("showTagMetadata", checked)
+ defaultValue: root.defaults.showTagMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.playStats.label")
+ description: pluginApi?.tr("settings.metadata.playStats.desc")
+ checked: root.showPlayStatsMetadata
+ onToggled: root.saveSetting("showPlayStatsMetadata", checked)
+ defaultValue: root.defaults.showPlayStatsMetadata ?? true
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.metadata.status.label")
+ description: pluginApi?.tr("settings.metadata.status.desc")
+ checked: root.showStatusMetadata
+ onToggled: root.saveSetting("showStatusMetadata", checked)
+ defaultValue: root.defaults.showStatusMetadata ?? true
+ }
+
+ NFilePicker {
+ id: downloadFolderPicker
+ selectionMode: "folders"
+ title: pluginApi?.tr("settings.downloads.folderPickerTitle")
+ initialPath: root.editDownloadDirectory || (Quickshell.env("HOME") + "/Music")
+ onAccepted: paths => {
+ if (paths.length > 0) {
+ root.editDownloadDirectory = paths[0];
+ root.applyDownloadDirectory();
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/i18n/en.json b/.config/noctalia/plugins/music-search/i18n/en.json
new file mode 100644
index 0000000..b15821c
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/i18n/en.json
@@ -0,0 +1,552 @@
+{
+ "plugin": {
+ "name": "music-search"
+ },
+ "command": {
+ "description": "Search with music-search ({provider}), play audio, and save favorites."
+ },
+ "common": {
+ "untitled": "Untitled",
+ "customUrl": "Custom URL",
+ "downloadedTrack": "Downloaded Track",
+ "queuedUrl": "Queued URL",
+ "music": "music-search",
+ "onePlay": "1 play",
+ "plays": "{count} plays"
+ },
+ "providers": {
+ "youtube": "YouTube",
+ "soundcloud": "SoundCloud",
+ "local": "Local"
+ },
+ "sort": {
+ "savedDate": "Saved date",
+ "date": "Date",
+ "title": "Title",
+ "duration": "Duration",
+ "rating": "Rating"
+ },
+ "status": {
+ "starting": "Starting playback",
+ "nowPlaying": "Now playing",
+ "ready": "music-search ready",
+ "backgroundPlayback": "Background mpv playback",
+ "searchPrompt": "Search {provider}, paste a URL, or open a saved track.",
+ "startingProviderPlayback": "Starting {provider} playback...",
+ "connectingYoutube": "Connecting to YouTube...",
+ "connectingSoundcloud": "Connecting to SoundCloud...",
+ "openingLocal": "Opening local track...",
+ "startingPlayback": "Starting playback..."
+ },
+ "notices": {
+ "libraryUpdated": "Library updated.",
+ "playlistsUpdated": "Playlists updated.",
+ "savedMp3": "Saved mp3 locally.",
+ "savedMp3To": "Saved mp3 to {path}",
+ "addedToQueue": "Added to queue.",
+ "folderImported": "Folder imported as playlist.",
+ "importingFolder": "Importing folder as playlist...",
+ "syncingFolder": "Syncing folder playlist...",
+ "savingMp3": "Saving current track as mp3...",
+ "downloadFolderUpdated": "Download folder updated.",
+ "cacheLimitUpdated": "MP3 cache limit updated.",
+ "playlistEmpty": "Playlist is empty.",
+ "switchedTo": "Switched to {provider}.",
+ "ytClientSet": "YouTube player client set to {client}.",
+ "sortSet": "Default sort set to {sort}.",
+ "addedPlaylistToQueue": "Added playlist \"{name}\" to queue.",
+ "addedShuffledPlaylistToQueue": "Added shuffled playlist \"{name}\" to queue.",
+ "playingPlaylist": "Playing playlist \"{name}\".",
+ "playingShuffledPlaylist": "Playing shuffled playlist \"{name}\"."
+ },
+ "errors": {
+ "playbackFailed": "music-search playback command failed.",
+ "libraryFailed": "music-search library command failed.",
+ "mp3SaveFailed": "Could not save mp3 locally.",
+ "queueFailed": "Could not add track to queue.",
+ "queuePlaylistTrackFailed": "Could not add playlist track to queue.",
+ "queueActionFailed": "Could not {action} queue.",
+ "folderImportFailed": "Could not import folder as playlist.",
+ "seekMalformed": "Seek response was malformed.",
+ "seekFailed": "Could not seek playback.",
+ "speedFailed": "Could not change playback speed.",
+ "searchMalformed": "Search results were malformed.",
+ "lastError": "Last error: {error}"
+ },
+ "search": {
+ "searching": "Searching {provider}",
+ "failed": "Search failed",
+ "failedDefault": "yt-dlp could not resolve results.",
+ "title": "Search with music-search",
+ "hint": "Try `>music-search burial`, `yt: burial`, `sc: artist`, `local: song`, `queue`, `#night`, `artist:name`, `rating:>=4`, `provider:local`, `playlist:name`, `speed:1.05`, or paste a URL.",
+ "noResults": "No saved or search results for \"{query}\".",
+ "typeMore": "Type at least 2 characters to search {provider}."
+ },
+ "library": {
+ "label": "Library",
+ "empty": "Library is empty",
+ "emptyDesc": "Save search results and they will show up here next time.",
+ "saved": "Saved",
+ "playlistOnly": "Playlist-only",
+ "savedTracks": "Saved tracks",
+ "libraryEmpty": "Your library is empty.",
+ "savedEmpty": "Your saved library is empty.",
+ "noMatches": "No saved tracks matched \"{query}\".",
+ "noFilterMatches": "No saved tracks matched your filters.",
+ "noTagged": "No tracks tagged \"{tag}\".",
+ "oneTrack": "1 saved track",
+ "trackCount": "{count} saved tracks"
+ },
+ "home": {
+ "recentlyPlayed": "Recently Played",
+ "recentlyPlayedDesc": "Your latest saved listens.",
+ "mostPlayed": "Most Played",
+ "mostPlayedDesc": "The tracks you come back to most.",
+ "tags": "Tags",
+ "tagsDesc": "Browse your library by mood and theme.",
+ "artists": "Artists",
+ "artistsDesc": "Jump through your saved library by uploader.",
+ "playlists": "Playlists",
+ "playlistsDesc": "Quick launch your saved lists.",
+ "recent": "Recent",
+ "recentWithTime": "Recent • {time}",
+ "top": "Top",
+ "topWithPlays": "Top • {plays}",
+ "artist": "Artist",
+ "artistWithTime": "Artist • {time}"
+ },
+ "queue": {
+ "title": "Queue",
+ "nextUp": "Next up",
+ "queued": "Queued",
+ "active": "Queue is active and waiting for the next finish.",
+ "idle": "Queue is idle.",
+ "start": "Start queue",
+ "startNow": "Begin queued playback now.",
+ "startArm": "Arm the queue or start the first queued track.",
+ "stop": "Stop queue",
+ "stopPause": "Pause queue mode without clearing the list.",
+ "stopKeep": "Disable auto-advance and keep queued tracks.",
+ "skip": "Skip to next",
+ "skipNow": "Start the next queued track now.",
+ "skipJump": "Jump to the next queued track.",
+ "clear": "Clear queue",
+ "clearRemove": "Remove all queued tracks.",
+ "clearEmpty": "Empty the queue list.",
+ "autoplaySaved": "Autoplay saved tracks",
+ "autoplaySavedDesc": "Load the saved library into queue and start playing it.",
+ "autoplaySavedActiveDesc": "Turn your saved music library into the active queue.",
+ "autoplaySavedShuffle": "Autoplay saved tracks (shuffle)",
+ "autoplaySavedShuffleDesc": "Shuffle the saved library into queue and start playing it.",
+ "autoplaySavedShuffleActiveDesc": "Shuffle your saved music library into the active queue.",
+ "empty": "Queue is empty",
+ "emptyDesc": "Use save, playlists, or inline queue actions to add tracks.",
+ "removed": "Removed queued track.",
+ "cleared": "Queue cleared.",
+ "playingSaved": "Playing saved tracks.",
+ "playingShuffledSaved": "Playing shuffled saved tracks.",
+ "savedEmpty": "Saved library is empty.",
+ "loaded": "Queue loaded.",
+ "loadedEmpty": "Queue is empty.",
+ "armed": "Queue armed. It will start after the current song finishes.",
+ "stopped": "Queue stopped.",
+ "noNext": "No next queued track.",
+ "loadingSaved": "Loading saved tracks into queue...",
+ "loadingShuffledSaved": "Loading saved tracks into shuffled queue...",
+ "nowPlaying": "Queued playback: {title}",
+ "errorExhausted": "Queue ran out after a playback error.",
+ "finished": "Queue finished."
+ },
+ "speed": {
+ "title": "Playback Speed",
+ "setTo": "Set speed to {speed}",
+ "desc": "Adjust current playback speed.",
+ "current": "Current: {speed}",
+ "multiplier": "{speed}x",
+ "noPlayback": "Start playback first, then use `speed:1.05` or the preview buttons.",
+ "useNumber": "Use a number like `speed:1.05`."
+ },
+ "import": {
+ "title": "Import folder as playlist",
+ "desc": "Use `import: /path/to/folder` to turn local audio into a playlist.",
+ "createPlaylist": "Create playlist \"{name}\" from {path}",
+ "hint": "Type `import: /path/to/folder` to import local audio files as a playlist."
+ },
+ "tags": {
+ "manage": "Manage tags",
+ "noTags": "No tags yet",
+ "oneTag": "1 tag",
+ "tagCount": "{count} tags",
+ "headerDescription": "{title} • {count}",
+ "editor": "Tag editor",
+ "chooseTrack": "Choose a saved track first, then use the tag action to edit its tags.",
+ "add": "Add #{tag}",
+ "remove": "Remove #{tag}",
+ "applyTo": "Apply to {title}",
+ "removeFrom": "Remove from {title}",
+ "noMatch": "No tag matches \"{query}\" yet. Select the add action to create it.",
+ "hint": "Type after `tag:` to add a new tag, or select an existing tag to remove it."
+ },
+ "metadata": {
+ "titleField": "Title",
+ "artistField": "Artist",
+ "albumField": "Album",
+ "label": "Metadata",
+ "edit": "Edit metadata",
+ "chooseTrack": "Choose a library track first, then use the edit action to update title, artist, or album.",
+ "fieldEditor": "{field} editor",
+ "headerDescription": "{title} • {value}",
+ "currentlyEmpty": "currently empty",
+ "editField": "Edit {field}",
+ "empty": "Currently empty",
+ "setField": "Set {field}",
+ "clearField": "Clear {field}",
+ "clearValue": "Clear value",
+ "chooseField": "Choose which field to edit for \"{title}\".",
+ "albumHint": "Type a new album name, or choose the clear action to remove it.",
+ "typeNew": "Type a new {field} value."
+ },
+ "playlists": {
+ "none": "No playlists",
+ "createHint": "Type `playlist:name` to create one.",
+ "untitled": "Untitled Playlist",
+ "oneTrack": "1 track",
+ "trackCount": "{count} tracks",
+ "syncedFolder": "synced folder",
+ "syncedFolderDescription": "{count} • {source}",
+ "rename": "Rename playlist",
+ "renameTitle": "Playlist rename",
+ "typeNewName": "Type a new name for \"{name}\".",
+ "typeDifferent": "Type a different name to rename this playlist.",
+ "alreadyExists": "Playlist \"{name}\" already exists.",
+ "renameTo": "Rename to \"{name}\"",
+ "updateTitle": "Update playlist title.",
+ "create": "Create playlist \"{name}\"",
+ "createAndAdd": "Create playlist \"{name}\" and add track",
+ "addAfterCreate": "Add {title} after creating it.",
+ "createNew": "Create a new playlist.",
+ "addTo": "Add to {name}",
+ "choose": "Choose a playlist",
+ "chooseFor": "Type a playlist name to create one for {title}.",
+ "chooseFirst": "Choose a playlist first, then use the rename action."
+ },
+ "actions": {
+ "stopMusic": "Stop music",
+ "stopTitle": "Stop {title}",
+ "stopDesc": "Stop background playback.",
+ "alreadyStopped": "music-search is already stopped",
+ "nothingPlaying": "Nothing is currently playing.",
+ "searchMprisTrack": "Search current MPRIS track",
+ "mprisTrackDesc": "Search \"{title}\" on {provider}.",
+ "mprisTrackDescWithPlayer": "Search \"{title}\" on {provider} from {player}.",
+ "playUrl": "Play URL",
+ "saveUrl": "Save URL to library",
+ "saveUrlMp3": "Save URL as mp3",
+ "downloadDesc": "Download to ~/Music/Noctalia"
+ },
+ "bar_widget": {
+ "tooltipIdle": "Open music panel",
+ "tooltipPlaying": "Open music panel\nNow playing: {title}",
+ "settings": "Widget settings"
+ },
+ "panel": {
+ "title": "Music panel",
+ "subtitle": "Search, queue, and control playback without opening the launcher.",
+ "openLauncher": "Launcher",
+ "openSettings": "Settings",
+ "close": "Close",
+ "hidePreview": "Hide preview",
+ "showPreview": "Show preview",
+ "search": "Search",
+ "library": "Saved",
+ "queue": "Queue",
+ "tracks": "Tracks",
+ "playlists": "Playlists",
+ "artists": "Artists",
+ "tags": "Tags",
+ "searchPlaceholder": "Search music, paste a URL, or use yt:/sc:/local:",
+ "libraryPlaceholder": "Filter saved tracks",
+ "playlistsPlaceholder": "Filter playlists",
+ "artistsPlaceholder": "Filter artists",
+ "tagsPlaceholder": "Filter tags",
+ "searchHint": "Search {provider}, paste a URL, or switch to Saved and Queue tabs.",
+ "typeMore": "Type at least 2 characters to search {provider}.",
+ "searching": "Searching {provider}...",
+ "noSearchResults": "No results for \"{query}\".",
+ "playUrl": "Play URL",
+ "saveUrl": "Save URL",
+ "queueUrl": "Queue URL",
+ "playSaved": "Play saved",
+ "shuffleSaved": "Shuffle saved",
+ "startQueue": "Start queue",
+ "skipQueue": "Skip",
+ "clearQueue": "Clear",
+ "pause": "Pause",
+ "resume": "Resume",
+ "stop": "Stop",
+ "speed": "Speed",
+ "saveCurrent": "Save track",
+ "saveCurrentMp3": "Save mp3",
+ "refresh": "Refresh",
+ "nowPlaying": "Now playing",
+ "nothingPlaying": "Nothing playing right now.",
+ "savedCount": "{count} saved tracks",
+ "queueCount": "{count} queued tracks",
+ "recentTracks": "Recent saved tracks",
+ "previewTitle": "Preview",
+ "queued": "Queued",
+ "queuedAt": "Queued {time}",
+ "emptyLibrary": "No saved tracks yet.",
+ "emptyQueue": "Queue is empty.",
+ "savedLabel": "Saved",
+ "playAction": "Play",
+ "queueAction": "Queue",
+ "saveAction": "Save",
+ "openAction": "Open",
+ "backAction": "Back",
+ "shuffleAction": "Shuffle",
+ "downloadAction": "MP3",
+ "removeAction": "Remove",
+ "playlistCount": "{count} playlists",
+ "artistCount": "{count} artists",
+ "tagCount": "{count} tags",
+ "helperHint": "The full command surface is still available in the launcher via >music-search.",
+ "emptyPlaylists": "No playlists matched this filter yet.",
+ "emptyArtists": "No saved artists matched this filter yet.",
+ "emptyTags": "No saved tags matched this filter yet.",
+ "emptyScopedLibrary": "No tracks are available in this section yet."
+ },
+ "tooltip": {
+ "addToQueue": "Add to queue",
+ "playNow": "Play now",
+ "saveToLibrary": "Save to library",
+ "saveMp3Current": "Save current track as mp3",
+ "resume": "Resume",
+ "pause": "Pause",
+ "editMetadata": "Edit metadata",
+ "manageTags": "Manage tags",
+ "addToPlaylist": "Add to playlist",
+ "addSavedToPlaylist": "Add saved track to playlist",
+ "switchProvider": "Switch provider ({provider})",
+ "sort": "Sort: {sort}",
+ "removeFromLibrary": "Remove from library",
+ "saveMp3": "Save as mp3",
+ "saveFirst": "Save first, then add to a playlist",
+ "saveUrlToLibrary": "Save URL to library",
+ "rate": "Rate ({rating})",
+ "playPlaylist": "Play playlist",
+ "shufflePlay": "Shuffle play",
+ "queuePlaylist": "Queue playlist",
+ "renamePlaylist": "Rename playlist",
+ "deletePlaylist": "Delete playlist",
+ "syncFolder": "Sync folder playlist",
+ "removeFromPlaylist": "Remove from playlist",
+ "removeFromQueue": "Remove from queue",
+ "unrated": "unrated"
+ },
+ "artists": {
+ "noArtists": "Save tracks with uploader metadata to browse artists here.",
+ "noMatch": "No saved artists matched \"{query}\"."
+ },
+ "preview": {
+ "chipSaved": "Saved",
+ "chipStarting": "Starting",
+ "length": "Length",
+ "album": "Album",
+ "plays": "Plays",
+ "lastPlayed": "Last played",
+ "uploaded": "Uploaded",
+ "reach": "Reach",
+ "status": "Status",
+ "lastPlaybackError": "Last playback error: {error}",
+ "readLess": "Read less",
+ "readMore": "Read more",
+ "loadingMetadata": "Loading richer metadata...",
+ "metadataParseError": "Metadata preview could not be parsed.",
+ "metadataUnavailable": "Metadata preview unavailable.",
+ "viewCount": "{count} views",
+ "viewCountB": "{count}B views",
+ "viewCountM": "{count}M views",
+ "viewCountK": "{count}K views"
+ },
+ "settings": {
+ "provider": {
+ "label": "Global provider",
+ "desc": "Choose the default provider used for normal music searches when you do not use a prefix like `yt:` or `sc:`."
+ },
+ "sort": {
+ "label": "Default sort",
+ "desc": "Choose how saved tracks are sorted in your library views."
+ },
+ "ytClient": {
+ "label": "YouTube player client",
+ "desc": "Which yt-dlp player client to use for YouTube streams. Try switching if playback fails or is throttled.",
+ "android": "Android",
+ "web": "Web",
+ "default": "Default (yt-dlp decides)"
+ },
+ "downloads": {
+ "title": "Downloads",
+ "currentFolder": "Current folder: {path}",
+ "chooseFolder": "Choose MP3 folder",
+ "applyFolder": "Apply folder",
+ "folderPickerTitle": "Choose MP3 download folder"
+ },
+ "cache": {
+ "label": "Max MP3 cache size",
+ "desc": "Limit the download folder size in MB. Older files are pruned after downloads. Set 0 for unlimited.",
+ "apply": "Apply cache limit"
+ },
+ "autoSave": {
+ "label": "Save remote tracks as mp3 after playback starts",
+ "desc": "After a YouTube or SoundCloud track successfully starts playing, also save an mp3 copy into the current download folder."
+ },
+ "bar": {
+ "title": "Bar Widget",
+ "hoverTitle": {
+ "label": "Show track title on hover",
+ "desc": "Expand the bar widget on hover to show and scroll the current track title."
+ }
+ },
+ "home": {
+ "title": "Home",
+ "recent": {
+ "label": "Show Recently Played",
+ "desc": "Show the recent listens section on the launcher home."
+ },
+ "top": {
+ "label": "Show Most Played",
+ "desc": "Show your top-played tracks on the launcher home."
+ },
+ "tags": {
+ "label": "Show Tags",
+ "desc": "Show tag shortcuts on the launcher home."
+ },
+ "artists": {
+ "label": "Show Artists",
+ "desc": "Show artist browse shortcuts on the launcher home."
+ },
+ "playlists": {
+ "label": "Show Playlists",
+ "desc": "Show playlist shortcuts on the launcher home."
+ }
+ },
+ "panel": {
+ "title": "Panel",
+ "defaultTab": {
+ "label": "Default panel tab",
+ "desc": "Choose which top-level tab opens first when you open the panel."
+ },
+ "defaultLibrarySection": {
+ "label": "Default Saved section",
+ "desc": "Choose which secondary section opens first inside the Saved tab."
+ },
+ "density": {
+ "label": "Panel density",
+ "desc": "Adjust spacing and card sizing in the panel.",
+ "compact": "Compact",
+ "balanced": "Balanced",
+ "roomy": "Roomy"
+ },
+ "header": {
+ "label": "Show panel header",
+ "desc": "Show the title row with launcher, settings, and close actions at the top of the panel."
+ },
+ "nowPlaying": {
+ "label": "Show now-playing block",
+ "desc": "Show the playback controls and current track block at the top of the panel."
+ },
+ "playbackProgress": {
+ "label": "Show playback progress",
+ "desc": "Show the seek bar and time row in the now-playing block."
+ },
+ "providerChips": {
+ "label": "Show provider chips",
+ "desc": "Show the YouTube, SoundCloud, and Local provider shortcuts on the Search tab."
+ },
+ "recentTracks": {
+ "label": "Show recent saved tracks",
+ "desc": "Show recently saved tracks below the panel search helper when the Search tab is empty."
+ },
+ "searchHelper": {
+ "label": "Show search helper",
+ "desc": "Show the search hint and launcher tip when the Search tab is empty."
+ },
+ "preview": {
+ "label": "Show track preview",
+ "desc": "Show a preview pane alongside the panel tabs. Click a track card header to inspect it."
+ },
+ "urlActions": {
+ "label": "Show URL actions",
+ "desc": "Show Play URL, Save URL, and Queue URL buttons when the search field contains a URL."
+ },
+ "speedControls": {
+ "label": "Show speed controls",
+ "desc": "Show playback speed controls in the now-playing area."
+ },
+ "queueControls": {
+ "label": "Show queue controls",
+ "desc": "Show the Start, Skip, and Clear buttons at the top of the Queue tab."
+ },
+ "statusBanner": {
+ "label": "Show status banner",
+ "desc": "Show success and error notices between the playback block and the tab area."
+ }
+ },
+ "preview": {
+ "title": "Preview",
+ "metadata": {
+ "label": "Rich Preview Metadata",
+ "desc": "Choose when the launcher preview should fetch thumbnails, descriptions, views, and upload details.",
+ "all": "All previews",
+ "playing": "Only playing track",
+ "disabled": "Disabled",
+ "neverHint": "Preview panels will stay lightweight and use only the metadata already present in the launcher results.",
+ "playingHint": "Only the currently playing item will fetch richer preview data. Saved and search results stay fast.",
+ "alwaysHint": "Every preview can fetch richer metadata, including thumbnails and long-form details."
+ },
+ "thumbnail": {
+ "label": "Thumbnail size",
+ "desc": "Choose how large the preview artwork appears in the side panel.",
+ "small": "Small",
+ "comfortable": "Comfortable",
+ "large": "Large"
+ },
+ "chips": {
+ "label": "Show preview chips",
+ "desc": "Show source, saved, starting, and tag chips above the track title in the preview."
+ }
+ },
+ "metadata": {
+ "title": "Visible Metadata",
+ "uploader": {
+ "label": "Show uploader",
+ "desc": "Display artist/uploader names in launcher results and preview panels."
+ },
+ "album": {
+ "label": "Show album",
+ "desc": "Display album metadata when it is available, especially for local files."
+ },
+ "duration": {
+ "label": "Show duration",
+ "desc": "Display track length in launcher descriptions and preview details."
+ },
+ "rating": {
+ "label": "Show ratings",
+ "desc": "Include star ratings in launcher descriptions for saved tracks."
+ },
+ "tags": {
+ "label": "Show tags",
+ "desc": "Include track tags in launcher descriptions."
+ },
+ "playStats": {
+ "label": "Show play stats",
+ "desc": "Display play counts and last-played information in the home view and preview."
+ },
+ "status": {
+ "label": "Show status",
+ "desc": "Display availability metadata like public or unlisted in rich preview details."
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/i18n/fr.json b/.config/noctalia/plugins/music-search/i18n/fr.json
new file mode 100644
index 0000000..619a8f7
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/i18n/fr.json
@@ -0,0 +1,552 @@
+{
+ "plugin": {
+ "name": "music-search"
+ },
+ "command": {
+ "description": "Rechercher avec music-search ({provider}), lire l'audio et enregistrer les favoris."
+ },
+ "common": {
+ "untitled": "Sans titre",
+ "customUrl": "URL personnalisée",
+ "downloadedTrack": "Piste téléchargée",
+ "queuedUrl": "URL en file d'attente",
+ "music": "music-search",
+ "onePlay": "1 lecture",
+ "plays": "{count} lectures"
+ },
+ "providers": {
+ "youtube": "YouTube",
+ "soundcloud": "SoundCloud",
+ "local": "Local"
+ },
+ "sort": {
+ "savedDate": "Date d'enregistrement",
+ "date": "Date",
+ "title": "Titre",
+ "duration": "Durée",
+ "rating": "Note"
+ },
+ "status": {
+ "starting": "Démarrage de la lecture",
+ "nowPlaying": "En cours de lecture",
+ "ready": "music-search prêt",
+ "backgroundPlayback": "Lecture mpv en arrière-plan",
+ "searchPrompt": "Recherchez {provider}, collez une URL ou ouvrez une piste enregistrée.",
+ "startingProviderPlayback": "Démarrage de la lecture {provider}...",
+ "connectingYoutube": "Connexion à YouTube...",
+ "connectingSoundcloud": "Connexion à SoundCloud...",
+ "openingLocal": "Ouverture de la piste locale...",
+ "startingPlayback": "Démarrage de la lecture..."
+ },
+ "notices": {
+ "libraryUpdated": "Bibliothèque mise à jour.",
+ "playlistsUpdated": "Listes de lecture mises à jour.",
+ "savedMp3": "MP3 enregistré localement.",
+ "savedMp3To": "MP3 enregistré dans {path}",
+ "addedToQueue": "Ajouté à la file d'attente.",
+ "folderImported": "Dossier importé comme liste de lecture.",
+ "importingFolder": "Importation du dossier comme liste de lecture...",
+ "syncingFolder": "Synchronisation du dossier de la liste de lecture...",
+ "savingMp3": "Enregistrement de la piste actuelle en MP3...",
+ "downloadFolderUpdated": "Dossier de téléchargement mis à jour.",
+ "cacheLimitUpdated": "Limite de cache MP3 mise à jour.",
+ "playlistEmpty": "La liste de lecture est vide.",
+ "switchedTo": "Passé à {provider}.",
+ "ytClientSet": "Client du lecteur YouTube réglé sur {client}.",
+ "sortSet": "Tri par défaut réglé sur {sort}.",
+ "addedPlaylistToQueue": "Liste de lecture \"{name}\" ajoutée à la file d'attente.",
+ "addedShuffledPlaylistToQueue": "Liste de lecture mélangée \"{name}\" ajoutée à la file d'attente.",
+ "playingPlaylist": "Lecture de la liste de lecture \"{name}\".",
+ "playingShuffledPlaylist": "Lecture de la liste de lecture mélangée \"{name}\"."
+ },
+ "errors": {
+ "playbackFailed": "La commande de lecture music-search a échoué.",
+ "libraryFailed": "La commande de bibliothèque music-search a échoué.",
+ "mp3SaveFailed": "Impossible d'enregistrer le MP3 localement.",
+ "queueFailed": "Impossible d'ajouter la piste à la file d'attente.",
+ "queuePlaylistTrackFailed": "Impossible d'ajouter la piste de la liste de lecture à la file d'attente.",
+ "queueActionFailed": "Impossible de {action} la file d'attente.",
+ "folderImportFailed": "Impossible d'importer le dossier comme liste de lecture.",
+ "seekMalformed": "La réponse de recherche était malformée.",
+ "seekFailed": "Impossible de rechercher dans la lecture.",
+ "speedFailed": "Impossible de changer la vitesse de lecture.",
+ "searchMalformed": "Les résultats de recherche étaient malformés.",
+ "lastError": "Dernière erreur : {error}"
+ },
+ "search": {
+ "searching": "Recherche sur {provider}",
+ "failed": "La recherche a échoué",
+ "failedDefault": "yt-dlp n'a pas pu résoudre les résultats.",
+ "title": "Rechercher avec music-search",
+ "hint": "Essayez `>music-search burial`, `yt: burial`, `sc: artist`, `local: song`, `queue`, `#night`, `artist:name`, `rating:>=4`, `provider:local`, `playlist:name`, `speed:1.05`, ou collez une URL.",
+ "noResults": "Aucun résultat enregistré ou de recherche pour \"{query}\".",
+ "typeMore": "Tapez au moins 2 caractères pour rechercher sur {provider}."
+ },
+ "library": {
+ "label": "Bibliothèque",
+ "empty": "La bibliothèque est vide",
+ "emptyDesc": "Enregistrez les résultats de recherche et ils apparaîtront ici la prochaine fois.",
+ "saved": "Enregistré",
+ "playlistOnly": "Liste de lecture uniquement",
+ "savedTracks": "Pistes enregistrées",
+ "libraryEmpty": "Votre bibliothèque est vide.",
+ "savedEmpty": "votre bibliothèque enregistrée est vide.",
+ "noMatches": "Aucune piste enregistrée ne correspond à \"{query}\".",
+ "noFilterMatches": "Aucune piste enregistrée ne correspond à vos filtres.",
+ "noTagged": "Aucune piste marquée \"{tag}\".",
+ "oneTrack": "1 piste enregistrée",
+ "trackCount": "{count} pistes enregistrées"
+ },
+ "home": {
+ "recentlyPlayed": "Écoutés récemment",
+ "recentlyPlayedDesc": "Vos dernières écoutes enregistrées.",
+ "mostPlayed": "Les plus écoutés",
+ "mostPlayedDesc": "Les pistes que vous écoutez le plus.",
+ "tags": "Tags",
+ "tagsDesc": "Parcourez votre bibliothèque par ambiance et thème.",
+ "artists": "Artistes",
+ "artistsDesc": "Naviguez dans votre bibliothèque enregistrée par auteur.",
+ "playlists": "Listes de lecture",
+ "playlistsDesc": "Lancez rapidement vos listes enregistrées.",
+ "recent": "Récent",
+ "recentWithTime": "Récent • {time}",
+ "top": "Top",
+ "topWithPlays": "Top • {plays}",
+ "artist": "Artiste",
+ "artistWithTime": "Artiste • {time}"
+ },
+ "queue": {
+ "title": "File d'attente",
+ "nextUp": "À suivre",
+ "queued": "En file d'attente",
+ "active": "La file d'attente est active et attend la fin de la piste actuelle.",
+ "idle": "La file d'attente est inactive.",
+ "start": "Démarrer la file d'attente",
+ "startNow": "Commencer la lecture de la file d'attente maintenant.",
+ "startArm": "Armer la file d'attente ou démarrer la première piste de la file.",
+ "stop": "Arrêter la file d'attente",
+ "stopPause": "Mettre en pause le mode file d'attente sans vider la liste.",
+ "stopKeep": "Désactiver l'avance automatique et conserver les pistes en file d'attente.",
+ "skip": "Passer au suivant",
+ "skipNow": "Démarrer la piste suivante de la file maintenant.",
+ "skipJump": "Sauter à la piste suivante de la file d'attente.",
+ "clear": "Vider la file d'attente",
+ "clearRemove": "Supprimer toutes les pistes de la file d'attente.",
+ "clearEmpty": "Vider la liste de la file d'attente.",
+ "autoplaySaved": "Lecture auto des pistes enregistrées",
+ "autoplaySavedDesc": "Charger la bibliothèque enregistrée dans la file d'attente et lancer la lecture.",
+ "autoplaySavedActiveDesc": "Transformer votre bibliothèque musicale enregistrée en file d'attente active.",
+ "autoplaySavedShuffle": "Lecture auto des pistes enregistrées (aléatoire)",
+ "autoplaySavedShuffleDesc": "Mélanger la bibliothèque enregistrée dans la file d'attente et lancer la lecture.",
+ "autoplaySavedShuffleActiveDesc": "Mélanger votre bibliothèque musicale enregistrée dans la file d'attente active.",
+ "empty": "La file d'attente est vide",
+ "emptyDesc": "Utilisez les actions d'enregistrement, les listes de lecture ou les actions en ligne pour ajouter des pistes.",
+ "removed": "Piste retirée de la file d'attente.",
+ "cleared": "File d'attente vidée.",
+ "playingSaved": "Lecture des pistes enregistrées.",
+ "playingShuffledSaved": "Lecture aléatoire des pistes enregistrées.",
+ "savedEmpty": "La bibliothèque enregistrée est vide.",
+ "loaded": "File d'attente chargée.",
+ "loadedEmpty": "La file d'attente est vide.",
+ "armed": "File d'attente armée. Elle démarrera après la fin de la chanson actuelle.",
+ "stopped": "File d'attente arrêtée.",
+ "noNext": "Pas de piste suivante en file d'attente.",
+ "loadingSaved": "Chargement des pistes enregistrées dans la file d'attente...",
+ "loadingShuffledSaved": "Chargement des pistes enregistrées dans la file d'attente mélangée...",
+ "nowPlaying": "Lecture en file d'attente : {title}",
+ "errorExhausted": "La file d'attente s'est épuisée après une erreur de lecture.",
+ "finished": "File d'attente terminée."
+ },
+ "speed": {
+ "title": "Vitesse de lecture",
+ "setTo": "Régler la vitesse à {speed}",
+ "desc": "Ajuster la vitesse de lecture actuelle.",
+ "current": "Actuelle : {speed}",
+ "multiplier": "{speed}x",
+ "noPlayback": "Démarrez d'abord la lecture, puis utilisez `speed:1.05` ou les boutons de prévisualisation.",
+ "useNumber": "Utilisez un nombre comme `speed:1.05`."
+ },
+ "import": {
+ "title": "Importer un dossier comme liste de lecture",
+ "desc": "Utilisez `import: /chemin/vers/dossier` pour transformer l'audio local en liste de lecture.",
+ "createPlaylist": "Créer la liste de lecture \"{name}\" à partir de {path}",
+ "hint": "Tapez `import: /chemin/vers/dossier` pour importer des fichiers audio locaux comme liste de lecture."
+ },
+ "tags": {
+ "manage": "Gérer les tags",
+ "noTags": "Pas encore de tags",
+ "oneTag": "1 tag",
+ "tagCount": "{count} tags",
+ "headerDescription": "{title} • {count}",
+ "editor": "Éditeur de tags",
+ "chooseTrack": "Choisissez d'abord une piste enregistrée, puis utilisez l'action tag pour éditer ses tags.",
+ "add": "Ajouter #{tag}",
+ "remove": "Supprimer #{tag}",
+ "applyTo": "Appliquer à {title}",
+ "removeFrom": "Retirer de {title}",
+ "noMatch": "Aucun tag ne correspond à \"{query}\" pour l'instant. Sélectionnez l'action d'ajout pour le créer.",
+ "hint": "Tapez après `tag:` pour ajouter un nouveau tag, ou sélectionnez un tag existant pour le supprimer."
+ },
+ "metadata": {
+ "titleField": "Titre",
+ "artistField": "Artiste",
+ "albumField": "Album",
+ "label": "Métadonnées",
+ "edit": "Modifier les métadonnées",
+ "chooseTrack": "Choisissez d'abord une piste de la bibliothèque, puis utilisez l'action de modification pour mettre à jour le titre, l'artiste ou l'album.",
+ "fieldEditor": "Éditeur de {field}",
+ "headerDescription": "{title} • {value}",
+ "currentlyEmpty": "actuellement vide",
+ "editField": "Modifier {field}",
+ "empty": "Actuellement vide",
+ "setField": "Définir {field}",
+ "clearField": "Effacer {field}",
+ "clearValue": "Effacer la valeur",
+ "chooseField": "Choisissez quel champ modifier pour \"{title}\".",
+ "albumHint": "Tapez un nouveau nom d'album, ou choisissez l'action d'effacement pour le supprimer.",
+ "typeNew": "Tapez une nouvelle valeur pour {field}."
+ },
+ "playlists": {
+ "none": "Aucune liste de lecture",
+ "createHint": "Tapez `playlist:nom` pour en créer une.",
+ "untitled": "Liste de lecture sans titre",
+ "oneTrack": "1 piste",
+ "trackCount": "{count} pistes",
+ "syncedFolder": "dossier synchronisé",
+ "syncedFolderDescription": "{count} • {source}",
+ "rename": "Renommer la liste de lecture",
+ "renameTitle": "Renommage de la liste de lecture",
+ "typeNewName": "Tapez un nouveau nom pour \"{name}\".",
+ "typeDifferent": "Tapez un nom différent pour renommer cette liste de lecture.",
+ "alreadyExists": "La liste de lecture \"{name}\" existe déjà .",
+ "renameTo": "Renommer en \"{name}\"",
+ "updateTitle": "Mettre à jour le titre de la liste de lecture.",
+ "create": "Créer la liste de lecture \"{name}\"",
+ "createAndAdd": "Créer la liste de lecture \"{name}\" et ajouter la piste",
+ "addAfterCreate": "Ajouter {title} après l'avoir créée.",
+ "createNew": "Créer une nouvelle liste de lecture.",
+ "addTo": "Ajouter à {name}",
+ "choose": "Choisir une liste de lecture",
+ "chooseFor": "Tapez un nom de liste de lecture pour en créer une pour {title}.",
+ "chooseFirst": "Choisissez d'abord une liste de lecture, puis utilisez l'action de renommage."
+ },
+ "actions": {
+ "stopMusic": "Arrêter la musique",
+ "stopTitle": "Arrêter {title}",
+ "stopDesc": "Arrêter la lecture en arrière-plan.",
+ "alreadyStopped": "music-search est déjà arrêté",
+ "nothingPlaying": "Rien n'est actuellement en cours de lecture.",
+ "searchMprisTrack": "Rechercher la piste MPRIS actuelle",
+ "mprisTrackDesc": "Rechercher \"{title}\" sur {provider}.",
+ "mprisTrackDescWithPlayer": "Rechercher \"{title}\" sur {provider} depuis {player}.",
+ "playUrl": "Lire l'URL",
+ "saveUrl": "Enregistrer l'URL dans la bibliothèque",
+ "saveUrlMp3": "Enregistrer l'URL en MP3",
+ "downloadDesc": "Télécharger dans ~/Music/Noctalia"
+ },
+ "bar_widget": {
+ "tooltipIdle": "Ouvrir le panneau musical",
+ "tooltipPlaying": "Ouvrir le panneau musical\nEn cours : {title}",
+ "settings": "Paramètres du widget"
+ },
+ "panel": {
+ "title": "Panneau musical",
+ "subtitle": "Recherchez, mettez en file d'attente et contrôlez la lecture sans ouvrir le lanceur.",
+ "openLauncher": "Lanceur",
+ "openSettings": "Paramètres",
+ "close": "Fermer",
+ "hidePreview": "Masquer l'aperçu",
+ "showPreview": "Afficher l'aperçu",
+ "search": "Recherche",
+ "library": "Enregistré",
+ "queue": "File d'attente",
+ "tracks": "Pistes",
+ "playlists": "Listes de lecture",
+ "artists": "Artistes",
+ "tags": "Tags",
+ "searchPlaceholder": "Rechercher de la musique, coller une URL ou utiliser yt:/sc:/local:",
+ "libraryPlaceholder": "Filtrer les pistes enregistrées",
+ "playlistsPlaceholder": "Filtrer les listes de lecture",
+ "artistsPlaceholder": "Filtrer les artistes",
+ "tagsPlaceholder": "Filtrer les tags",
+ "searchHint": "Recherchez sur {provider}, collez une URL ou passez aux onglets Enregistré et File d'attente.",
+ "typeMore": "Tapez au moins 2 caractères pour rechercher sur {provider}.",
+ "searching": "Recherche sur {provider}...",
+ "noSearchResults": "Aucun résultat pour \"{query}\".",
+ "playUrl": "Lire l'URL",
+ "saveUrl": "Enregistrer l'URL",
+ "queueUrl": "File d'attente URL",
+ "playSaved": "Lire enregistré",
+ "shuffleSaved": "Aléatoire enregistré",
+ "startQueue": "Démarrer file",
+ "skipQueue": "Passer",
+ "clearQueue": "Vider",
+ "pause": "Pause",
+ "resume": "Reprendre",
+ "stop": "Arrêter",
+ "speed": "Vitesse",
+ "saveCurrent": "Enregistrer piste",
+ "saveCurrentMp3": "Enregistrer MP3",
+ "refresh": "Actualiser",
+ "nowPlaying": "En cours de lecture",
+ "nothingPlaying": "Rien ne joue actuellement.",
+ "savedCount": "{count} pistes enregistrées",
+ "queueCount": "{count} pistes en file",
+ "recentTracks": "Pistes enregistrées récentes",
+ "previewTitle": "Aperçu",
+ "queued": "En file",
+ "queuedAt": "En file à {time}",
+ "emptyLibrary": "Pas encore de pistes enregistrées.",
+ "emptyQueue": "La file d'attente est vide.",
+ "savedLabel": "Enregistré",
+ "playAction": "Lire",
+ "queueAction": "File",
+ "saveAction": "Enregistrer",
+ "openAction": "Ouvrir",
+ "backAction": "Retour",
+ "shuffleAction": "Aléatoire",
+ "downloadAction": "MP3",
+ "removeAction": "Retirer",
+ "playlistCount": "{count} listes de lecture",
+ "artistCount": "{count} artistes",
+ "tagCount": "{count} tags",
+ "helperHint": "Toute la surface de commande est toujours disponible dans le lanceur via >music-search.",
+ "emptyPlaylists": "Aucune liste de lecture ne correspond à ce filtre pour le moment.",
+ "emptyArtists": "Aucun artiste enregistré ne correspond à ce filtre pour le moment.",
+ "emptyTags": "Aucun tag enregistré ne correspond à ce filtre pour le moment.",
+ "emptyScopedLibrary": "Aucune piste n'est disponible dans cette section pour le moment."
+ },
+ "tooltip": {
+ "addToQueue": "Ajouter à la file d'attente",
+ "playNow": "Lire maintenant",
+ "saveToLibrary": "Enregistrer dans la bibliothèque",
+ "saveMp3Current": "Enregistrer la piste actuelle en MP3",
+ "resume": "Reprendre",
+ "pause": "Pause",
+ "editMetadata": "Modifier les métadonnées",
+ "manageTags": "Gérer les tags",
+ "addToPlaylist": "Ajouter à la liste de lecture",
+ "addSavedToPlaylist": "Ajouter la piste enregistrée à la liste de lecture",
+ "switchProvider": "Changer de fournisseur ({provider})",
+ "sort": "Tri : {sort}",
+ "removeFromLibrary": "Retirer de la bibliothèque",
+ "saveMp3": "Enregistrer en MP3",
+ "saveFirst": "Enregistrez d'abord, puis ajoutez à une liste de lecture",
+ "saveUrlToLibrary": "Enregistrer l'URL dans la bibliothèque",
+ "rate": "Noter ({rating})",
+ "playPlaylist": "Lire la liste de lecture",
+ "shufflePlay": "Lecture aléatoire",
+ "queuePlaylist": "Mettre la liste en file d'attente",
+ "renamePlaylist": "Renommer la liste de lecture",
+ "deletePlaylist": "Supprimer la liste de lecture",
+ "syncFolder": "Synchroniser le dossier de la liste",
+ "removeFromPlaylist": "Retirer de la liste de lecture",
+ "removeFromQueue": "Retirer de la file d'attente",
+ "unrated": "non noté"
+ },
+ "artists": {
+ "noArtists": "Enregistrez des pistes avec des métadonnées d'auteur pour parcourir les artistes ici.",
+ "noMatch": "Aucun artiste enregistré ne correspond à \"{query}\"."
+ },
+ "preview": {
+ "chipSaved": "Enregistré",
+ "chipStarting": "Démarrage",
+ "length": "Longueur",
+ "album": "Album",
+ "plays": "Lectures",
+ "lastPlayed": "Dernière lecture",
+ "uploaded": "Mis en ligne",
+ "reach": "Portée",
+ "status": "Statut",
+ "lastPlaybackError": "Dernière erreur de lecture : {error}",
+ "readLess": "Lire moins",
+ "readMore": "Lire plus",
+ "loadingMetadata": "Chargement de métadonnées enrichies...",
+ "metadataParseError": "L'aperçu des métadonnées n'a pas pu être analysé.",
+ "metadataUnavailable": "Aperçu des métadonnées indisponible.",
+ "viewCount": "{count} vues",
+ "viewCountB": "{count} Md de vues",
+ "viewCountM": "{count} M de vues",
+ "viewCountK": "{count} k de vues"
+ },
+ "settings": {
+ "provider": {
+ "label": "Fournisseur global",
+ "desc": "Choisissez le fournisseur par défaut utilisé pour les recherches musicales normales lorsque vous n'utilisez pas de préfixe comme `yt:` ou `sc:`."
+ },
+ "sort": {
+ "label": "Tri par défaut",
+ "desc": "Choisissez comment les pistes enregistrées sont triées dans les vues de votre bibliothèque."
+ },
+ "ytClient": {
+ "label": "Client du lecteur YouTube",
+ "desc": "Quel client yt-dlp utiliser pour les flux YouTube. Essayez de changer si la lecture échoue ou est ralentie.",
+ "android": "Android",
+ "web": "Web",
+ "default": "Par défaut (yt-dlp décide)"
+ },
+ "downloads": {
+ "title": "Téléchargements",
+ "currentFolder": "Dossier actuel : {path}",
+ "chooseFolder": "Choisir le dossier MP3",
+ "applyFolder": "Appliquer le dossier",
+ "folderPickerTitle": "Choisir le dossier de téléchargement MP3"
+ },
+ "cache": {
+ "label": "Taille max du cache MP3",
+ "desc": "Limitez la taille du dossier de téléchargement en Mo. Les fichiers plus anciens sont purgés après les téléchargements. Réglez à 0 pour illimité.",
+ "apply": "Appliquer la limite de cache"
+ },
+ "autoSave": {
+ "label": "Enregistrer les pistes distantes en MP3 après le début de la lecture",
+ "desc": "Une fois qu'une piste YouTube ou SoundCloud a commencé à jouer avec succès, enregistrez également une copie MP3 dans le dossier de téléchargement actuel."
+ },
+ "bar": {
+ "title": "Widget de barre",
+ "hoverTitle": {
+ "label": "Afficher le titre de la piste au survol",
+ "desc": "Agrandir le widget de barre au survol pour afficher et faire défiler le titre de la piste actuelle."
+ }
+ },
+ "home": {
+ "title": "Accueil",
+ "recent": {
+ "label": "Afficher les écoutés récemment",
+ "desc": "Afficher la section des écoutes récentes sur l'accueil du lanceur."
+ },
+ "top": {
+ "label": "Afficher les plus écoutés",
+ "desc": "Afficher vos pistes les plus écoutées sur l'accueil du lanceur."
+ },
+ "tags": {
+ "label": "Afficher les Tags",
+ "desc": "Afficher les raccourcis de tags sur l'accueil du lanceur."
+ },
+ "artists": {
+ "label": "Afficher les Artistes",
+ "desc": "Afficher les raccourcis de navigation par artiste sur l'accueil du lanceur."
+ },
+ "playlists": {
+ "label": "Afficher les Listes de lecture",
+ "desc": "Afficher les raccourcis de listes de lecture sur l'accueil du lanceur."
+ }
+ },
+ "panel": {
+ "title": "Panneau",
+ "defaultTab": {
+ "label": "Onglet par défaut du panneau",
+ "desc": "Choisissez quel onglet de premier niveau s'ouvre en premier lors de l'ouverture du panneau."
+ },
+ "defaultLibrarySection": {
+ "label": "Section Enregistré par défaut",
+ "desc": "Choisissez quelle section secondaire s'ouvre en premier dans l'onglet Enregistré."
+ },
+ "density": {
+ "label": "Densité du panneau",
+ "desc": "Ajuster l'espacement et la taille des cartes dans le panneau.",
+ "compact": "Compact",
+ "balanced": "Équilibré",
+ "roomy": "Spacieux"
+ },
+ "header": {
+ "label": "Afficher l'en-tête du panneau",
+ "desc": "Afficher la ligne de titre avec les actions lanceur, paramètres et fermeture en haut du panneau."
+ },
+ "nowPlaying": {
+ "label": "Afficher le bloc En cours de lecture",
+ "desc": "Afficher les contrôles de lecture et le bloc de la piste actuelle en haut du panneau."
+ },
+ "playbackProgress": {
+ "label": "Afficher la progression de la lecture",
+ "desc": "Afficher la barre de recherche et la ligne de temps dans le bloc En cours de lecture."
+ },
+ "providerChips": {
+ "label": "Afficher les jetons de fournisseur",
+ "desc": "Afficher les raccourcis des fournisseurs YouTube, SoundCloud et Local sur l'onglet Recherche."
+ },
+ "recentTracks": {
+ "label": "Afficher les pistes enregistrées récentes",
+ "desc": "Afficher les pistes récemment enregistrées sous l'aide à la recherche du panneau lorsque l'onglet Recherche est vide."
+ },
+ "searchHelper": {
+ "label": "Afficher l'aide à la recherche",
+ "desc": "Afficher l'astuce de recherche et le conseil du lanceur lorsque l'onglet Recherche est vide."
+ },
+ "preview": {
+ "label": "Afficher l'aperçu de la piste",
+ "desc": "Afficher un volet d'aperçu à côté des onglets du panneau. Cliquez sur l'en-tête d'une carte de piste pour l'inspecter."
+ },
+ "urlActions": {
+ "label": "Afficher les actions d'URL",
+ "desc": "Afficher les boutons Lire l'URL, Enregistrer l'URL et File d'attente URL lorsque le champ de recherche contient une URL."
+ },
+ "speedControls": {
+ "label": "Afficher les contrôles de vitesse",
+ "desc": "Afficher les contrôles de vitesse de lecture dans la zone En cours de lecture."
+ },
+ "queueControls": {
+ "label": "Afficher les contrôles de file d'attente",
+ "desc": "Afficher les boutons Démarrer, Passer et Vider en haut de l'onglet File d'attente."
+ },
+ "statusBanner": {
+ "label": "Afficher la bannière de statut",
+ "desc": "Afficher les notifications de succès et d'erreur entre le bloc de lecture et la zone des onglets."
+ }
+ },
+ "preview": {
+ "title": "Aperçu",
+ "metadata": {
+ "label": "Métadonnées d'aperçu enrichies",
+ "desc": "Choisissez quand l'aperçu du lanceur doit récupérer les miniatures, descriptions, vues et détails de mise en ligne.",
+ "all": "Tous les aperçus",
+ "playing": "Uniquement la piste en cours",
+ "disabled": "Désactivé",
+ "neverHint": "Les panneaux d'aperçu resteront légers et utiliseront uniquement les métadonnées déjà présentes dans les résultats du lanceur.",
+ "playingHint": "Seul l'élément en cours de lecture récupérera des données d'aperçu enrichies. Les résultats enregistrés et de recherche restent rapides.",
+ "alwaysHint": "Chaque aperçu peut récupérer des métadonnées enrichies, y compris les miniatures et les détails longs."
+ },
+ "thumbnail": {
+ "label": "Taille de la miniature",
+ "desc": "Choisissez la taille de l'illustration d'aperçu dans le panneau latéral.",
+ "small": "Petite",
+ "comfortable": "Confortable",
+ "large": "Grande"
+ },
+ "chips": {
+ "label": "Afficher les jetons d'aperçu",
+ "desc": "Afficher les jetons de source, d'enregistrement, de démarrage et de tag au-dessus du titre de la piste dans l'aperçu."
+ }
+ },
+ "metadata": {
+ "title": "Métadonnées visibles",
+ "uploader": {
+ "label": "Afficher l'auteur",
+ "desc": "Afficher les noms d'artiste/auteur dans les résultats du lanceur et les panneaux d'aperçu."
+ },
+ "album": {
+ "label": "Afficher l'album",
+ "desc": "Afficher les métadonnées de l'album lorsqu'elles sont disponibles, en particulier pour les fichiers locaux."
+ },
+ "duration": {
+ "label": "Afficher la durée",
+ "desc": "Afficher la longueur de la piste dans les descriptions du lanceur et les détails de l'aperçu."
+ },
+ "rating": {
+ "label": "Afficher les notes",
+ "desc": "Inclure les notes par étoiles dans les descriptions du lanceur pour les pistes enregistrées."
+ },
+ "tags": {
+ "label": "Afficher les tags",
+ "desc": "Inclure les tags de la piste dans les descriptions du lanceur."
+ },
+ "playStats": {
+ "label": "Afficher les stats de lecture",
+ "desc": "Afficher le nombre de lectures et les informations de dernière lecture dans la vue d'accueil et l'aperçu."
+ },
+ "status": {
+ "label": "Afficher le statut",
+ "desc": "Afficher les métadonnées de disponibilité comme public ou non répertorié dans les détails de l'aperçu enrichi."
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/lib/config.sh b/.config/noctalia/plugins/music-search/lib/config.sh
new file mode 100644
index 0000000..8911321
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/config.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# config.sh — constants, directory setup, and helpers
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+DEFAULT_CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}"
+DEFAULT_CACHE_DIR="${DEFAULT_CACHE_HOME}/noctalia/plugins/music-search"
+CACHE_DIR="${MUSIC_CACHE_DIR:-${DEFAULT_CACHE_DIR}}"
+PID_FILE="${CACHE_DIR}/mpv.pid"
+STATE_FILE="${CACHE_DIR}/state.json"
+LIBRARY_FILE="${CACHE_DIR}/library.json"
+LOG_FILE="${CACHE_DIR}/mpv.log"
+SOCKET_FILE="${CACHE_DIR}/mpv.sock"
+SETTINGS_FILE="${CACHE_DIR}/settings.json"
+PLAYLISTS_FILE="${CACHE_DIR}/playlists.json"
+QUEUE_FILE="${CACHE_DIR}/queue.json"
+DOWNLOAD_BASENAME="${CACHE_DIR}/current-media"
+DOWNLOADS_DIR_DEFAULT="${HOME}/Music/Noctalia"
+LOCAL_MUSIC_DIR="${HOME}/Music"
+
+STATE_LOCK="${CACHE_DIR}/state.lock"
+DATA_LOCK="${CACHE_DIR}/data.lock"
+SETTINGS_LOCK="${CACHE_DIR}/settings.lock"
+QUEUE_LOCK="${CACHE_DIR}/queue.lock"
+
+mkdir -p "$CACHE_DIR"
+
+die() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+require_cmd() {
+ local name="$1"
+ command -v "$name" >/dev/null 2>&1 || die "Missing dependency: $name"
+}
+
+with_lock() {
+ local lock_file="$1"; shift
+ local _wl_fd
+ exec {_wl_fd}>"$lock_file"
+ flock "$_wl_fd"
+ local _wl_rc=0
+ "$@" || _wl_rc=$?
+ exec {_wl_fd}>&-
+ return $_wl_rc
+}
+
+json_write_raw() {
+ local target="$1"
+ local tmp_file
+ tmp_file="$(mktemp "${target}.tmp.XXXXXX")"
+ if ! cat > "$tmp_file"; then
+ rm -f "$tmp_file"
+ die "json_write_raw: write failed for $target"
+ fi
+ if [[ ! -s "$tmp_file" ]]; then
+ rm -f "$tmp_file"
+ die "json_write_raw: refusing empty write for $target"
+ fi
+ if ! jq empty "$tmp_file" 2>/dev/null; then
+ rm -f "$tmp_file"
+ die "json_write_raw: produced invalid JSON for $target"
+ fi
+ [[ -f "$target" ]] && cp "$target" "${target}.bak"
+ mv "$tmp_file" "$target"
+}
+
+safe_read_json() {
+ local target="$1"
+ [[ -f "$target" ]] || return 0
+ if jq empty "$target" 2>/dev/null; then return 0; fi
+
+ # Quarantine corrupted file
+ local ts
+ ts="$(date +%s)"
+ mv "$target" "${target}.corrupt.${ts}"
+
+ # Restore from backup if valid
+ if [[ -f "${target}.bak" ]] && jq empty "${target}.bak" 2>/dev/null; then
+ cp "${target}.bak" "$target"
+ return 0
+ fi
+
+ # state.json can be reinitialized; user data cannot
+ if [[ "$target" == "$STATE_FILE" ]]; then
+ _ensure_state_file
+ return 0
+ fi
+
+ die "Corrupted: ${target}.corrupt.${ts} — no valid backup. Manual recovery required."
+}
+
+_ensure_state_file() {
+ jq -nc \
+ --arg id "" \
+ --arg title "" \
+ --arg url "" \
+ --arg uploader "" \
+ --arg error "" \
+ --arg endReason "" \
+ --arg updatedAt "0" \
+ '{isPlaying:false,isPaused:false,id:$id,title:$title,url:$url,uploader:$uploader,duration:0,speed:1,pid:0,error:$error,endReason:$endReason,updatedAt:$updatedAt}' > "$STATE_FILE"
+}
+
+yt_player_client() {
+ local configured=""
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ configured="$(jq -r '.ytPlayerClient // empty' "$SETTINGS_FILE" 2>/dev/null || true)"
+ fi
+ printf '%s\n' "${configured:-android}"
+}
+
+yt_extractor_args() {
+ local client
+ client="$(yt_player_client)"
+ if [[ "$client" == "default" || -z "$client" ]]; then
+ return 0
+ fi
+ printf '%s\n' "--extractor-args=youtube:player_client=${client}"
+}
+
+yt_mpv_raw_option() {
+ local client
+ client="$(yt_player_client)"
+ if [[ "$client" == "default" || -z "$client" ]]; then
+ return 0
+ fi
+ printf '%s\n' "--ytdl-raw-options=extractor-args=youtube:player_client=${client}"
+}
+
+downloads_dir() {
+ local configured=""
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ configured="$(jq -r '.downloadDirectory // empty' "$SETTINGS_FILE" 2>/dev/null || true)"
+ fi
+ printf '%s\n' "${configured:-$DOWNLOADS_DIR_DEFAULT}"
+}
+
+download_cache_max_mb() {
+ local configured=""
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ configured="$(jq -r '.downloadCacheMaxMb // empty' "$SETTINGS_FILE" 2>/dev/null || true)"
+ fi
+ if [[ "$configured" =~ ^[0-9]+$ ]]; then
+ printf '%s\n' "$configured"
+ else
+ printf '0\n'
+ fi
+}
+
+prune_download_cache() {
+ local target_dir max_mb limit_bytes total_bytes oldest_path
+ target_dir="$(downloads_dir)"
+ max_mb="$(download_cache_max_mb)"
+
+ if [[ ! "$max_mb" =~ ^[0-9]+$ ]] || (( max_mb <= 0 )) || [[ ! -d "$target_dir" ]]; then
+ return 0
+ fi
+
+ limit_bytes=$((max_mb * 1024 * 1024))
+ total_bytes="$(find "$target_dir" -maxdepth 1 -type f -printf '%s\n' 2>/dev/null | awk '{sum+=$1} END {print sum+0}')"
+
+ while [[ "$total_bytes" =~ ^[0-9]+$ ]] && (( total_bytes > limit_bytes )); do
+ oldest_path="$(find "$target_dir" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | head -n 1 | cut -d' ' -f2-)"
+ if [[ -z "$oldest_path" ]]; then
+ break
+ fi
+ rm -f -- "$oldest_path" || break
+ total_bytes="$(find "$target_dir" -maxdepth 1 -type f -printf '%s\n' 2>/dev/null | awk '{sum+=$1} END {print sum+0}')"
+ done
+}
+
+ensure_files() {
+ [[ -f "$STATE_FILE" ]] || _ensure_state_file
+ [[ -f "$LIBRARY_FILE" ]] || printf '[]\n' > "$LIBRARY_FILE"
+ [[ -f "$SETTINGS_FILE" ]] || jq -nc \
+ --arg provider "youtube" \
+ --arg sortBy "date" \
+ --arg downloadDirectory "$DOWNLOADS_DIR_DEFAULT" \
+ --argjson downloadCacheMaxMb 0 \
+ '{activeProvider:$provider,sortBy:$sortBy,downloadDirectory:$downloadDirectory,downloadCacheMaxMb:$downloadCacheMaxMb}' > "$SETTINGS_FILE"
+ [[ -f "$PLAYLISTS_FILE" ]] || printf '[]\n' > "$PLAYLISTS_FILE"
+ [[ -f "$QUEUE_FILE" ]] || printf '[]\n' > "$QUEUE_FILE"
+}
diff --git a/.config/noctalia/plugins/music-search/lib/launch.sh b/.config/noctalia/plugins/music-search/lib/launch.sh
new file mode 100644
index 0000000..28fe883
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/launch.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# launch.sh — mpv launch helpers
+
+launch_youtube_stream() {
+ local source_url="$1"
+ local speed="${2-1}"
+ local -a mpv_cmd=(
+ mpv
+ --no-video
+ --force-window=no
+ --audio-display=no
+ --ytdl-format=bestaudio/best
+ --demuxer-max-bytes=256K
+ --speed="$speed"
+ --log-file="$LOG_FILE"
+ --input-ipc-server="$SOCKET_FILE"
+ --title="Noctalia music-search"
+ )
+ local raw_opt
+ raw_opt="$(yt_mpv_raw_option)"
+ [[ -n "$raw_opt" ]] && mpv_cmd+=("$raw_opt")
+ mpv_cmd+=("$source_url")
+
+ nohup setsid "${mpv_cmd[@]}" >/dev/null 2>&1 &
+}
+
+launch_youtube_cached() {
+ local source_url="$1"
+ local speed="${2-1}"
+ local extractor_arg
+ extractor_arg="$(yt_extractor_args)"
+
+ nohup setsid bash -lc '
+ set -euo pipefail
+ shopt -s nullglob
+ rm -f "$2".*
+ ytdlp_args=(yt-dlp --ignore-config --no-warnings -f bestaudio/best --no-part -o "$2.%(ext)s")
+ [[ -n "$5" ]] && ytdlp_args+=("$5")
+ "${ytdlp_args[@]}" -- "$1" >>"$4" 2>&1
+ files=("$2".*)
+ if [[ ${#files[@]} -eq 0 ]]; then
+ exit 1
+ fi
+ exec mpv --no-video --force-window=no --audio-display=no --demuxer-max-bytes=256K --speed="$6" --log-file="$4" --input-ipc-server="$3" --title="Noctalia music-search" "${files[0]}"
+ ' _ "$source_url" "$DOWNLOAD_BASENAME" "$SOCKET_FILE" "$LOG_FILE" "$extractor_arg" "$speed" >/dev/null 2>&1 &
+}
+
+wait_for_audio_start() {
+ local pid="$1"
+ local attempts="${2-24}"
+
+ for _ in $(seq 1 "$attempts"); do
+ if [[ -f "$LOG_FILE" ]] && grep -q "starting audio playback" "$LOG_FILE" 2>/dev/null; then
+ return 0
+ fi
+
+ if ! is_running_pid "$pid"; then
+ return 1
+ fi
+
+ sleep 0.25
+ done
+
+ return 2
+}
diff --git a/.config/noctalia/plugins/music-search/lib/library.sh b/.config/noctalia/plugins/music-search/lib/library.sh
new file mode 100644
index 0000000..13ee312
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/library.sh
@@ -0,0 +1,524 @@
+#!/usr/bin/env bash
+# library.sh — library CRUD, playback recording, search dispatch, details, download
+
+_record_library_playback_unlocked() {
+ local entry_id="${1-}"
+ local title="${2-}"
+ local url="${3-}"
+ local uploader="${4-}"
+ local duration="${5-0}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" && -z "$url" ]]; then
+ return 0
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! jq -e --arg id "$entry_id" --arg url "$url" \
+ 'any(.[]?; (($id != "" and .id == $id) or ($url != "" and .url == $url)))' \
+ "$LIBRARY_FILE" >/dev/null 2>&1; then
+ return 0
+ fi
+
+ local played_at provider
+ played_at="$(date -Iseconds)"
+ provider="$(infer_provider_for_url "$url")"
+
+ jq \
+ --arg id "$entry_id" \
+ --arg title "$title" \
+ --arg url "$url" \
+ --arg uploader "$uploader" \
+ --argjson duration "${duration:-0}" \
+ --arg playedAt "$played_at" \
+ --arg provider "$provider" \
+ 'map(
+ if (($id != "" and .id == $id) or ($url != "" and .url == $url)) then
+ .title = (if ($title != "") then $title else (.title // "Untitled") end)
+ | .url = (if ($url != "") then $url else (.url // "") end)
+ | .uploader = (if ($uploader != "") then $uploader else (.uploader // "") end)
+ | .duration = (if $duration > 0 then $duration else (.duration // 0) end)
+ | .provider = (if (.provider // "") != "" then .provider else $provider end)
+ | .playCount = ((.playCount // 0) + 1)
+ | .lastPlayedAt = $playedAt
+ else
+ .
+ end
+ )' "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+}
+
+record_library_playback() {
+ with_lock "$DATA_LOCK" _record_library_playback_unlocked "$@"
+}
+
+_play_track_state_phase() {
+ local entry_id="$1"
+ local title="$2"
+ local url="$3"
+ local uploader="$4"
+ local duration="$5"
+ local desired_speed="1"
+
+ local playback_source="$url"
+ local youtube_video_id=""
+ local downloaded_youtube_file=""
+ local -a mpv_args
+ local pid=""
+ local stream_result=1
+
+ if [[ -z "$title" || -z "$url" ]]; then
+ _write_state_unlocked false "$entry_id" "$title" "$url" "$uploader" 0 1 0 "Missing playback parameters." "error"
+ _emit_state_unlocked
+ exit 1
+ fi
+
+ require_cmd mpv
+
+ desired_speed="$(current_state_speed)"
+
+ _stop_existing_unlocked >/dev/null 2>&1 || true
+
+ : > "$LOG_FILE"
+
+ if [[ "$url" =~ youtube\.com|youtu\.be ]]; then
+ youtube_video_id="$(extract_youtube_video_id "$url" 2>/dev/null || true)"
+ downloaded_youtube_file="$(find_downloaded_youtube_file "$youtube_video_id" 2>/dev/null || true)"
+
+ if [[ -n "$downloaded_youtube_file" && -f "$downloaded_youtube_file" ]]; then
+ playback_source="$downloaded_youtube_file"
+ mpv_args=(
+ mpv
+ --no-video
+ --force-window=no
+ --audio-display=no
+ --demuxer-max-bytes=256K
+ --speed="$desired_speed"
+ --log-file="$LOG_FILE"
+ --input-ipc-server="$SOCKET_FILE"
+ --title="Noctalia music-search"
+ "$playback_source"
+ )
+
+ nohup setsid "${mpv_args[@]}" >/dev/null 2>&1 &
+ else
+ if ! command -v yt-dlp >/dev/null 2>&1; then
+ _write_state_unlocked false "$entry_id" "$title" "$url" "$uploader" "$duration" "$desired_speed" 0 "yt-dlp is required for YouTube playback." "error"
+ _emit_state_unlocked
+ exit 1
+ fi
+
+ launch_youtube_stream "$url" "$desired_speed"
+ pid="$!"
+ printf '%s\n' "$pid" > "$PID_FILE"
+
+ if wait_for_audio_start "$pid" 24; then
+ _write_state_unlocked true "$entry_id" "$title" "$url" "$uploader" "$duration" "$desired_speed" "$pid" "" ""
+ _emit_state_unlocked
+ return 0
+ fi
+
+ stream_result=$?
+
+ if is_running_pid "$pid"; then
+ terminate_pid "$pid"
+ sleep 0.3
+ if is_running_pid "$pid"; then
+ force_terminate_pid "$pid"
+ fi
+ fi
+
+ rm -f "$PID_FILE"
+ cleanup_runtime_cache
+ : > "$LOG_FILE"
+
+ launch_youtube_cached "$url" "$desired_speed"
+ fi
+ else
+ mpv_args=(
+ mpv
+ --no-video
+ --force-window=no
+ --audio-display=no
+ --ytdl-format="bestaudio/best"
+ --demuxer-max-bytes=256K
+ --speed="$desired_speed"
+ --log-file="$LOG_FILE"
+ --input-ipc-server="$SOCKET_FILE"
+ --title="Noctalia music-search"
+ "$url"
+ )
+
+ nohup setsid "${mpv_args[@]}" >/dev/null 2>&1 &
+ fi
+
+ pid="$!"
+ printf '%s\n' "$pid" > "$PID_FILE"
+ sleep 1.5
+
+ if is_running_pid "$pid"; then
+ _write_state_unlocked true "$entry_id" "$title" "$url" "$uploader" "$duration" "$desired_speed" "$pid" "" ""
+ _emit_state_unlocked
+ return 0
+ fi
+
+ local error=""
+ if [[ -f "$LOG_FILE" ]]; then
+ error="$(tail -n 1 "$LOG_FILE" 2>/dev/null || true)"
+ fi
+
+ rm -f "$PID_FILE"
+ _write_state_unlocked false "$entry_id" "$title" "$url" "$uploader" "$duration" "$desired_speed" 0 "${error:-$([[ "$stream_result" -eq 2 ]] && printf 'Streaming did not start in time, and cached fallback failed.' || printf 'Track failed to start.')}" "error"
+ _emit_state_unlocked
+ exit 1
+}
+
+play_track() {
+ local entry_id="${1-}"
+ local title="${2-}"
+ local url="${3-}"
+ local uploader="${4-}"
+ local duration="${5-0}"
+
+ _play_track_state_phase "$entry_id" "$title" "$url" "$uploader" "$duration"
+
+ record_library_playback "$entry_id" "$title" "$url" "$uploader" "$duration" || true
+ exit 0
+}
+
+search_tracks() {
+ local query="${1-}"
+ local provider="${2-}"
+ require_cmd jq
+
+ if [[ -z "$query" ]]; then
+ printf '[]\n'
+ exit 0
+ fi
+
+ if [[ -z "$provider" ]]; then
+ provider="$(get_provider)"
+ fi
+
+ case "$provider" in
+ youtube)
+ require_cmd yt-dlp
+ search_youtube "$query"
+ ;;
+ soundcloud)
+ require_cmd yt-dlp
+ search_soundcloud "$query"
+ ;;
+ local)
+ search_local "$query"
+ ;;
+ *)
+ require_cmd yt-dlp
+ search_youtube "$query"
+ ;;
+ esac
+}
+
+details_for_url() {
+ local url="${1-}"
+ require_cmd jq
+
+ if [[ -z "$url" ]]; then
+ die "Missing URL."
+ fi
+
+ if [[ -f "$url" ]]; then
+ local local_metadata local_title local_uploader local_album local_duration
+ local_metadata="$(local_file_metadata_json "$url")"
+ local_title="$(printf '%s' "$local_metadata" | jq -r '.title // "Untitled"' 2>/dev/null || printf '%s' "$(basename "$url")")"
+ local_uploader="$(printf '%s' "$local_metadata" | jq -r '.uploader // "Local file"' 2>/dev/null || printf 'Local file')"
+ local_album="$(printf '%s' "$local_metadata" | jq -r '.album // ""' 2>/dev/null || true)"
+ local_duration="$(printf '%s' "$local_metadata" | jq -r '.duration // 0' 2>/dev/null || printf '0')"
+
+ jq -nc \
+ --arg title "$local_title" \
+ --arg fileUrl "$url" \
+ --arg uploader "$local_uploader" \
+ --arg album "$local_album" \
+ --arg localPath "$url" \
+ --argjson duration "${local_duration:-0}" \
+ '{
+ title: $title,
+ url: $fileUrl,
+ uploader: $uploader,
+ channel: $uploader,
+ album: $album,
+ localPath: $localPath,
+ duration: $duration,
+ uploadDate: "",
+ viewCount: 0,
+ availability: "local",
+ thumbnail: "",
+ description: (if $album != "" then ("Local audio file • " + $album) else "Local audio file" end)
+ }'
+ exit 0
+ fi
+
+ require_cmd yt-dlp
+
+ local -a ytdlp_args
+ ytdlp_args=(yt-dlp --ignore-config --dump-single-json --no-warnings --playlist-items 1)
+ if [[ "$url" =~ youtube\.com|youtu\.be ]]; then
+ local extractor_arg
+ extractor_arg="$(yt_extractor_args)"
+ [[ -n "$extractor_arg" ]] && ytdlp_args+=("$extractor_arg")
+ fi
+
+ "${ytdlp_args[@]}" -- "$url" 2>/dev/null \
+ | jq -c --arg fallbackUrl "$url" '{
+ id: (.id // ""),
+ title: (.title // "Untitled"),
+ url: (.webpage_url // .url // $fallbackUrl),
+ uploader: (.uploader // .channel // ""),
+ channel: (.channel // .uploader // ""),
+ duration: (.duration // 0),
+ uploadDate: (.upload_date // ""),
+ viewCount: (.view_count // 0),
+ availability: (.availability // ""),
+ thumbnail: (.thumbnail // ""),
+ description: (.description // "")
+ }'
+}
+
+download_mp3() {
+ local title="${1-}"
+ local url="${2-}"
+ local target_dir
+
+ if [[ -z "$url" ]]; then
+ die "Missing URL."
+ fi
+
+ target_dir="$(downloads_dir)"
+ mkdir -p "$target_dir"
+
+ if [[ -f "$url" ]]; then
+ require_cmd ffmpeg
+
+ local base_name output_path
+ base_name="$(sanitize_filename "${title:-$(basename "$url")}")"
+ base_name="${base_name%.*}"
+ output_path="${target_dir}/${base_name}.mp3"
+
+ ffmpeg -y -i "$url" -vn -codec:a libmp3lame -q:a 2 "$output_path" >/dev/null 2>&1
+ prune_download_cache
+ printf '%s\n' "$output_path"
+ exit 0
+ fi
+
+ require_cmd yt-dlp
+ require_cmd ffmpeg
+
+ local -a dl_args=(yt-dlp --ignore-config --no-warnings --no-part --no-playlist -x --audio-format mp3 --audio-quality 0)
+ if [[ "$url" =~ youtube\.com|youtu\.be ]]; then
+ local extractor_arg
+ extractor_arg="$(yt_extractor_args)"
+ [[ -n "$extractor_arg" ]] && dl_args+=("$extractor_arg")
+ dl_args+=(-o "${target_dir}/%(title)s [yt-%(id)s].%(ext)s")
+ else
+ dl_args+=(-o "${target_dir}/%(title)s [%(id)s].%(ext)s")
+ fi
+ "${dl_args[@]}" --print after_move:filepath -- "$url"
+ prune_download_cache
+}
+
+_save_entry_unlocked() {
+ local entry_id="${1-}"
+ local title="${2-}"
+ local url="${3-}"
+ local uploader="${4-}"
+ local duration="${5-0}"
+ local is_saved="${6-true}"
+ local provider album local_path metadata
+
+ require_cmd jq
+
+ if [[ -z "$url" ]]; then
+ die "Missing URL."
+ fi
+
+ if [[ -z "$entry_id" ]]; then
+ entry_id="saved-$(hash_value "$url")"
+ fi
+
+ if [[ -z "$title" ]]; then
+ title="Untitled"
+ fi
+
+ provider="$(infer_provider_for_url "$url")"
+ album=""
+ local_path=""
+
+ if [[ -f "$url" ]]; then
+ metadata="$(local_file_metadata_json "$url")"
+ title="$(printf '%s' "$metadata" | jq -r --arg fallback "$title" '.title // $fallback' 2>/dev/null || printf '%s' "$title")"
+ uploader="$(printf '%s' "$metadata" | jq -r --arg fallback "$uploader" '.uploader // $fallback' 2>/dev/null || printf '%s' "$uploader")"
+ duration="$(printf '%s' "$metadata" | jq -r --argjson fallback "${duration:-0}" '.duration // $fallback' 2>/dev/null || printf '%s' "${duration:-0}")"
+ album="$(printf '%s' "$metadata" | jq -r '.album // ""' 2>/dev/null || true)"
+ local_path="$(printf '%s' "$metadata" | jq -r '.localPath // ""' 2>/dev/null || true)"
+ provider="local"
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ jq \
+ --arg id "$entry_id" \
+ --arg title "$title" \
+ --arg url "$url" \
+ --arg uploader "$uploader" \
+ --argjson duration "${duration:-0}" \
+ --argjson isSaved "${is_saved:-true}" \
+ --arg savedAt "$(date -Iseconds)" \
+ --arg provider "$provider" \
+ --arg album "$album" \
+ --arg localPath "$local_path" \
+ '(map(select(.id == $id or .url == $url)) | first // null) as $existing |
+ map(select(.id != $id and .url != $url)) + [{
+ id: $id,
+ title: $title,
+ url: $url,
+ uploader: $uploader,
+ duration: $duration,
+ savedAt: $savedAt,
+ provider: (if $provider != "" then $provider else ($existing.provider // "") end),
+ album: (if $album != "" then $album else ($existing.album // "") end),
+ localPath: (if $localPath != "" then $localPath else ($existing.localPath // "") end),
+ isSaved: (if $isSaved == true then true else ($existing.isSaved // false) end),
+ tags: ($existing.tags // []),
+ rating: ($existing.rating // 0),
+ playCount: ($existing.playCount // 0),
+ lastPlayedAt: ($existing.lastPlayedAt // "")
+ }]' "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+
+ cat "$LIBRARY_FILE"
+}
+
+save_entry() {
+ with_lock "$DATA_LOCK" _save_entry_unlocked "$@"
+}
+
+_edit_metadata_unlocked() {
+ local entry_id="${1-}"
+ local field="${2-}"
+ local value="${3-}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing entry id."
+ fi
+
+ if [[ -z "$field" ]]; then
+ die "Missing metadata field."
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ case "$field" in
+ title)
+ if [[ -z "$value" ]]; then
+ die "Title cannot be empty."
+ fi
+ jq --arg id "$entry_id" --arg value "$value" \
+ 'map(if .id == $id then .title = $value else . end)' "$LIBRARY_FILE" \
+ | json_write_raw "$LIBRARY_FILE"
+ ;;
+ artist|uploader)
+ jq --arg id "$entry_id" --arg value "$value" \
+ 'map(if .id == $id then .uploader = $value else . end)' "$LIBRARY_FILE" \
+ | json_write_raw "$LIBRARY_FILE"
+ ;;
+ album)
+ jq --arg id "$entry_id" --arg value "$value" \
+ 'map(if .id == $id then .album = $value else . end)' "$LIBRARY_FILE" \
+ | json_write_raw "$LIBRARY_FILE"
+ ;;
+ *)
+ die "Unsupported metadata field: $field"
+ ;;
+ esac
+
+ cat "$LIBRARY_FILE"
+}
+
+edit_metadata() {
+ with_lock "$DATA_LOCK" _edit_metadata_unlocked "$@"
+}
+
+save_url() {
+ local url="${1-}"
+ local fallback_id
+ require_cmd yt-dlp
+ require_cmd jq
+
+ if [[ -z "$url" ]]; then
+ die "Missing URL."
+ fi
+
+ fallback_id="saved-$(hash_value "$url")"
+ local metadata
+ local -a ytdlp_args
+ ytdlp_args=(yt-dlp --ignore-config --dump-single-json --no-warnings --playlist-items 1)
+ if [[ "$url" =~ youtube\.com|youtu\.be ]]; then
+ local extractor_arg
+ extractor_arg="$(yt_extractor_args)"
+ [[ -n "$extractor_arg" ]] && ytdlp_args+=("$extractor_arg")
+ fi
+
+ metadata="$("${ytdlp_args[@]}" -- "$url" 2>/dev/null \
+ | jq -c --arg fallbackUrl "$url" --arg fallbackId "$fallback_id" '{
+ id: (.id // $fallbackId),
+ title: (.title // "Saved Track"),
+ url: (.webpage_url // .url // $fallbackUrl),
+ uploader: (.channel // .uploader // ""),
+ duration: (.duration // 0)
+ }')"
+
+ if [[ -z "$metadata" ]]; then
+ die "Could not resolve metadata for URL."
+ fi
+
+ save_entry \
+ "$(jq -r '.id' <<< "$metadata")" \
+ "$(jq -r '.title' <<< "$metadata")" \
+ "$(jq -r '.url' <<< "$metadata")" \
+ "$(jq -r '.uploader' <<< "$metadata")" \
+ "$(jq -r '.duration' <<< "$metadata")"
+}
+
+_remove_entry_unlocked() {
+ local entry_id="${1-}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing library entry id."
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+ safe_read_json "$PLAYLISTS_FILE"
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ jq --arg id "$entry_id" 'map(select(.id != $id))' "$LIBRARY_FILE" \
+ | json_write_raw "$LIBRARY_FILE"
+
+ jq --arg id "$entry_id" \
+ 'map(.entryIds = ((.entryIds // []) | map(select(. != $id))))' \
+ "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+
+ cat "$LIBRARY_FILE"
+}
+
+remove_entry() {
+ with_lock "$DATA_LOCK" _remove_entry_unlocked "$@"
+}
diff --git a/.config/noctalia/plugins/music-search/lib/media-utils.sh b/.config/noctalia/plugins/music-search/lib/media-utils.sh
new file mode 100644
index 0000000..862d15d
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/media-utils.sh
@@ -0,0 +1,216 @@
+#!/usr/bin/env bash
+# media-utils.sh — filename sanitization, URL parsing, local file metadata
+
+sanitize_filename() {
+ local value="${1-}"
+ value="${value//$'\n'/ }"
+ value="${value//$'\r'/ }"
+ value="${value//\//-}"
+ value="$(printf '%s' "$value" | sed 's/[<>:\"\\|?*]/-/g; s/[[:space:]]\+/ /g; s/^ //; s/ $//')"
+ printf '%s' "${value:-track}"
+}
+
+strip_known_media_id_suffix() {
+ local value="${1-}"
+ local stripped
+ stripped="$(printf '%s' "$value" | sed -E 's/ \[(yt-)?[A-Za-z0-9_-]{6,}\]$//')"
+ printf '%s' "${stripped:-$value}"
+}
+
+extract_youtube_video_id() {
+ local url="${1-}"
+ local short_regex='youtu\.be/([A-Za-z0-9_-]{6,})'
+ local watch_regex='[?&]v=([A-Za-z0-9_-]{6,})'
+ local path_regex='youtube\.com/(shorts|embed|live)/([A-Za-z0-9_-]{6,})'
+
+ if [[ "$url" =~ $short_regex ]]; then
+ printf '%s\n' "${BASH_REMATCH[1]}"
+ return 0
+ fi
+
+ if [[ "$url" =~ $watch_regex ]]; then
+ printf '%s\n' "${BASH_REMATCH[1]}"
+ return 0
+ fi
+
+ if [[ "$url" =~ $path_regex ]]; then
+ printf '%s\n' "${BASH_REMATCH[2]}"
+ return 0
+ fi
+
+ return 1
+}
+
+find_downloaded_youtube_file() {
+ local video_id="${1-}"
+ local filepath=""
+ local target_dir
+ target_dir="$(downloads_dir)"
+
+ if [[ -z "$video_id" || ! -d "$target_dir" ]]; then
+ return 1
+ fi
+
+ while IFS= read -r -d '' filepath; do
+ printf '%s\n' "$filepath"
+ return 0
+ done < <(
+ find "$target_dir" -maxdepth 1 -type f \
+ \( \
+ -iname "*\[yt-${video_id}\].mp3" -o \
+ -iname "*\[yt-${video_id}\].m4a" -o \
+ -iname "*\[yt-${video_id}\].opus" -o \
+ -iname "*\[yt-${video_id}\].ogg" -o \
+ -iname "*\[yt-${video_id}\].webm" -o \
+ -iname "*\[${video_id}\].mp3" -o \
+ -iname "*\[${video_id}\].m4a" -o \
+ -iname "*\[${video_id}\].opus" -o \
+ -iname "*\[${video_id}\].ogg" -o \
+ -iname "*\[${video_id}\].webm" \
+ \) \
+ -print0 2>/dev/null
+ )
+
+ return 1
+}
+
+infer_provider_for_url() {
+ local url="${1-}"
+ if [[ -f "$url" ]]; then
+ printf 'local\n'
+ elif [[ "$url" =~ soundcloud\.com ]]; then
+ printf 'soundcloud\n'
+ elif [[ "$url" =~ youtube\.com|youtu\.be ]]; then
+ printf 'youtube\n'
+ else
+ printf 'custom\n'
+ fi
+}
+
+local_file_metadata_json() {
+ local filepath="${1-}"
+ require_cmd jq
+
+ if [[ -z "$filepath" || ! -f "$filepath" ]]; then
+ printf '{}\n'
+ return 0
+ fi
+
+ local filename base_name display_name guessed_title guessed_artist album duration probe_json
+ filename="$(basename "$filepath")"
+ base_name="${filename%.*}"
+ display_name="$(strip_known_media_id_suffix "$base_name")"
+ guessed_title="$display_name"
+ guessed_artist="Local file"
+ album=""
+ duration="0"
+
+ if [[ "$display_name" == *" - "* ]]; then
+ guessed_artist="${display_name%% - *}"
+ guessed_title="${display_name#* - }"
+ elif [[ "$display_name" == *" — "* ]]; then
+ guessed_artist="${display_name%% — *}"
+ guessed_title="${display_name#* — }"
+ elif [[ "$display_name" == *" – "* ]]; then
+ guessed_artist="${display_name%% – *}"
+ guessed_title="${display_name#* – }"
+ fi
+
+ if command -v ffprobe >/dev/null 2>&1; then
+ probe_json="$(ffprobe -v quiet -print_format json \
+ -show_entries format=duration:format_tags=title,artist,album,album_artist \
+ "$filepath" 2>/dev/null || true)"
+
+ if [[ -n "$probe_json" ]]; then
+ local probe_title probe_artist probe_album probe_duration
+ probe_title="$(printf '%s' "$probe_json" | jq -r '.format.tags.title // empty' 2>/dev/null || true)"
+ probe_artist="$(printf '%s' "$probe_json" | jq -r '.format.tags.artist // .format.tags.album_artist // empty' 2>/dev/null || true)"
+ probe_album="$(printf '%s' "$probe_json" | jq -r '.format.tags.album // empty' 2>/dev/null || true)"
+ probe_duration="$(printf '%s' "$probe_json" | jq -r '(.format.duration // 0 | tonumber? // 0 | floor)' 2>/dev/null || true)"
+
+ if [[ -n "$probe_title" ]]; then
+ guessed_title="$probe_title"
+ fi
+ if [[ -n "$probe_artist" ]]; then
+ guessed_artist="$probe_artist"
+ fi
+ if [[ -n "$probe_album" ]]; then
+ album="$probe_album"
+ fi
+ if [[ -n "$probe_duration" && "$probe_duration" != "null" ]]; then
+ duration="$probe_duration"
+ fi
+ fi
+ fi
+
+ jq -nc \
+ --arg id "local-$(printf '%s' "$filepath" | sha256sum | cut -c1-16)" \
+ --arg title "${guessed_title:-$display_name}" \
+ --arg url "$filepath" \
+ --arg uploader "${guessed_artist:-Local file}" \
+ --arg album "$album" \
+ --arg localPath "$filepath" \
+ --arg provider "local" \
+ --argjson duration "${duration:-0}" \
+ '{
+ id: $id,
+ title: $title,
+ url: $url,
+ uploader: $uploader,
+ duration: $duration,
+ album: $album,
+ localPath: $localPath,
+ provider: $provider
+ }'
+}
+
+local_audio_candidates() {
+ local query="${1-}"
+ if command -v rg >/dev/null 2>&1; then
+ local -a rg_args
+ rg_args=(
+ rg --files "$LOCAL_MUSIC_DIR"
+ -g '*.mp3'
+ -g '*.flac'
+ -g '*.ogg'
+ -g '*.opus'
+ -g '*.m4a'
+ -g '*.wav'
+ -g '*.wma'
+ -g '*.aac'
+ )
+
+ if [[ -n "$query" ]]; then
+ "${rg_args[@]}" 2>/dev/null | rg -i -F -- "$query" 2>/dev/null || true
+ else
+ "${rg_args[@]}" 2>/dev/null || true
+ fi
+ else
+ find "$LOCAL_MUSIC_DIR" -maxdepth 4 -type f \
+ \( -iname '*.mp3' -o -iname '*.flac' -o -iname '*.ogg' -o -iname '*.opus' -o -iname '*.m4a' -o -iname '*.wav' -o -iname '*.wma' -o -iname '*.aac' \) \
+ -print 2>/dev/null || true
+ fi
+}
+
+folder_audio_candidates() {
+ local folder="${1-}"
+ if [[ -z "$folder" || ! -d "$folder" ]]; then
+ return 0
+ fi
+
+ if command -v rg >/dev/null 2>&1; then
+ rg --files "$folder" \
+ -g '*.mp3' \
+ -g '*.flac' \
+ -g '*.ogg' \
+ -g '*.opus' \
+ -g '*.m4a' \
+ -g '*.wav' \
+ -g '*.wma' \
+ -g '*.aac' 2>/dev/null || true
+ else
+ find "$folder" -type f \
+ \( -iname '*.mp3' -o -iname '*.flac' -o -iname '*.ogg' -o -iname '*.opus' -o -iname '*.m4a' -o -iname '*.wav' -o -iname '*.wma' -o -iname '*.aac' \) \
+ -print 2>/dev/null || true
+ fi
+}
diff --git a/.config/noctalia/plugins/music-search/lib/mpv-ipc.sh b/.config/noctalia/plugins/music-search/lib/mpv-ipc.sh
new file mode 100644
index 0000000..3c28134
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/mpv-ipc.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# mpv-ipc.sh — mpv IPC communication and status
+
+_current_status_commit_unlocked() {
+ local pid="${1-}"
+ local is_running="${2-false}"
+ local pause_state="${3-false}"
+ local speed_state="${4-1}"
+ safe_read_json "$STATE_FILE"
+
+ local previous_playing
+ local previous_end_reason
+ local next_end_reason
+
+ previous_playing="$(state_field '.isPlaying')"
+ previous_end_reason="$(state_field '.endReason // ""')"
+
+ if [[ "$is_running" == "true" ]]; then
+ _write_state_unlocked \
+ true \
+ "$(state_field '.id')" \
+ "$(state_field '.title')" \
+ "$(state_field '.url')" \
+ "$(state_field '.uploader')" \
+ "$(state_field '.duration')" \
+ "$speed_state" \
+ "$pid" \
+ "" \
+ "" \
+ "$pause_state"
+ else
+ rm -f "$PID_FILE"
+ cleanup_runtime_cache
+ next_end_reason="$previous_end_reason"
+ if [[ "$previous_playing" == "true" ]]; then
+ if [[ "$previous_end_reason" == "stopped" ]]; then
+ next_end_reason="stopped"
+ else
+ next_end_reason="finished"
+ fi
+ fi
+ _write_state_unlocked \
+ false \
+ "$(state_field '.id')" \
+ "$(state_field '.title')" \
+ "$(state_field '.url')" \
+ "$(state_field '.uploader')" \
+ "$(state_field '.duration')" \
+ "$(current_state_speed)" \
+ 0 \
+ "" \
+ "$next_end_reason" \
+ "false"
+ fi
+
+ _emit_state_unlocked
+}
+
+current_status() {
+ local pid=""
+ local pause_state="false"
+ local speed_state="1"
+ local is_running="false"
+
+ pid="$(read_pid)"
+ if is_running_pid "$pid"; then
+ is_running="true"
+
+ local pause_response=""
+ pause_response="$(mpv_ipc_send '{"command":["get_property","pause"]}' 2>/dev/null || true)"
+ if [[ -n "$pause_response" ]]; then
+ local pause_val
+ pause_val="$(printf '%s' "$pause_response" | jq -r '.data // false' 2>/dev/null || true)"
+ if [[ "$pause_val" == "true" ]]; then
+ pause_state="true"
+ fi
+ fi
+
+ local speed_response=""
+ speed_response="$(mpv_ipc_send '{"command":["get_property","speed"]}' 2>/dev/null || true)"
+ if [[ -n "$speed_response" ]]; then
+ local speed_val
+ speed_val="$(printf '%s' "$speed_response" | jq -r '.data // 1' 2>/dev/null || true)"
+ if [[ -n "$speed_val" && "$speed_val" != "null" ]]; then
+ speed_state="$speed_val"
+ fi
+ fi
+ fi
+
+ _current_status_commit_unlocked "$pid" "$is_running" "$pause_state" "$speed_state"
+}
+
+mpv_ipc_send() {
+ local payload="$1"
+ [[ -S "$SOCKET_FILE" ]] || return 1
+ if command -v socat >/dev/null 2>&1; then
+ printf '%s\n' "$payload" | socat -t 1 - UNIX-CONNECT:"$SOCKET_FILE" 2>/dev/null | head -1 || true
+ elif command -v python3 >/dev/null 2>&1; then
+ python3 - "$SOCKET_FILE" "$payload" 2>/dev/null <<'PYEOF'
+import sys, socket as sock
+path, msg = sys.argv[1], sys.argv[2]
+s = sock.socket(sock.AF_UNIX, sock.SOCK_STREAM)
+s.settimeout(1)
+try:
+ s.connect(path); s.sendall((msg+'\n').encode())
+ print(s.recv(4096).decode().strip().split('\n')[0])
+except Exception: pass
+finally: s.close()
+PYEOF
+ else
+ return 1
+ fi
+}
+
+mpv_ipc_command() {
+ local payload="$1"
+ local action_label="$2"
+ require_cmd jq
+
+ local response=""
+ response="$(mpv_ipc_send "$payload" 2>/dev/null || true)"
+ if [[ -z "$response" ]]; then
+ write_current_state_error_and_emit "Could not ${action_label} playback: mpv IPC is unavailable."
+ return 1
+ fi
+
+ local ipc_error=""
+ ipc_error="$(printf '%s' "$response" | jq -r '.error // "success"' 2>/dev/null || true)"
+ if [[ "$ipc_error" != "success" ]]; then
+ write_current_state_error_and_emit "Could not ${action_label} playback: ${ipc_error}."
+ return 1
+ fi
+
+ return 0
+}
diff --git a/.config/noctalia/plugins/music-search/lib/playback.sh b/.config/noctalia/plugins/music-search/lib/playback.sh
new file mode 100644
index 0000000..109935a
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/playback.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+# playback.sh — pause, resume, position, seek, speed
+
+pause_playback() {
+ local pid
+ pid="$(read_pid)"
+
+ if ! is_running_pid "$pid"; then
+ emit_state
+ return 0
+ fi
+
+ if ! mpv_ipc_command '{"command":["set_property","pause",true]}' "pause"; then
+ exit 1
+ fi
+
+ current_status
+}
+
+resume_playback() {
+ local pid
+ pid="$(read_pid)"
+
+ if ! is_running_pid "$pid"; then
+ emit_state
+ return 0
+ fi
+
+ if ! mpv_ipc_command '{"command":["set_property","pause",false]}' "resume"; then
+ exit 1
+ fi
+
+ current_status
+}
+
+position_query() {
+ local response
+ response="$(mpv_ipc_send '{"command":["get_property","playback-time"]}' 2>/dev/null || true)"
+ if [[ -n "$response" ]]; then
+ local pos
+ pos="$(printf '%s' "$response" | jq '.data // 0' 2>/dev/null || true)"
+ if [[ -n "$pos" && "$pos" != "null" ]]; then
+ printf '{"position":%s}\n' "$pos"
+ return 0
+ fi
+ fi
+ printf '{"position":0}\n'
+}
+
+speed_query() {
+ local response
+ response="$(mpv_ipc_send '{"command":["get_property","speed"]}' 2>/dev/null || true)"
+ if [[ -n "$response" ]]; then
+ local speed
+ speed="$(printf '%s' "$response" | jq '.data // 1' 2>/dev/null || true)"
+ if [[ -n "$speed" && "$speed" != "null" ]]; then
+ printf '{"speed":%s}\n' "$speed"
+ return 0
+ fi
+ fi
+ printf '{"speed":1}\n'
+}
+
+seek_absolute() {
+ local target_position="${1-}"
+ require_cmd jq
+
+ if [[ -z "$target_position" ]]; then
+ die "Missing seek position."
+ fi
+
+ local normalized_position
+ if ! normalized_position="$(jq -nr --arg value "$target_position" '$value | tonumber | if . < 0 then 0 else . end' 2>/dev/null)"; then
+ die "Invalid seek position: $target_position"
+ fi
+
+ local pid
+ pid="$(read_pid)"
+
+ if ! is_running_pid "$pid"; then
+ die "Playback is not running."
+ fi
+
+ local payload
+ payload="$(jq -cn --argjson pos "$normalized_position" '{"command":["seek", $pos, "absolute+exact"]}')"
+
+ local response=""
+ response="$(mpv_ipc_send "$payload" 2>/dev/null || true)"
+ if [[ -z "$response" ]]; then
+ die "Could not seek playback: mpv IPC is unavailable."
+ fi
+
+ local ipc_error=""
+ ipc_error="$(printf '%s' "$response" | jq -r '.error // "success"' 2>/dev/null || true)"
+ if [[ "$ipc_error" != "success" ]]; then
+ die "Could not seek playback: $ipc_error."
+ fi
+
+ current_status >/dev/null
+ position_query
+}
+
+set_speed() {
+ local target_speed="${1-}"
+ require_cmd jq
+
+ if [[ -z "$target_speed" ]]; then
+ die "Missing speed value."
+ fi
+
+ local normalized_speed
+ if ! normalized_speed="$(jq -nr --arg value "$target_speed" '$value | tonumber | if . < 0.25 then 0.25 elif . > 4 then 4 else . end' 2>/dev/null)"; then
+ die "Invalid speed value: $target_speed"
+ fi
+
+ local pid
+ pid="$(read_pid)"
+
+ if ! is_running_pid "$pid"; then
+ die "Playback is not running."
+ fi
+
+ local payload
+ payload="$(jq -cn --argjson speed "$normalized_speed" '{"command":["set_property", "speed", $speed]}')"
+
+ local response=""
+ response="$(mpv_ipc_send "$payload" 2>/dev/null || true)"
+ if [[ -z "$response" ]]; then
+ die "Could not change speed: mpv IPC is unavailable."
+ fi
+
+ local ipc_error=""
+ ipc_error="$(printf '%s' "$response" | jq -r '.error // "success"' 2>/dev/null || true)"
+ if [[ "$ipc_error" != "success" ]]; then
+ die "Could not change speed: $ipc_error."
+ fi
+
+ current_status
+}
diff --git a/.config/noctalia/plugins/music-search/lib/playlists.sh b/.config/noctalia/plugins/music-search/lib/playlists.sh
new file mode 100644
index 0000000..70f4fb2
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/playlists.sh
@@ -0,0 +1,413 @@
+#!/usr/bin/env bash
+# playlists.sh — playlist CRUD and library listing
+
+_create_playlist_unlocked() {
+ local name="${1-}"
+ require_cmd jq
+
+ if [[ -z "$name" ]]; then
+ die "Missing playlist name."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+
+ if playlist_name_exists "$name"; then
+ die "Playlist already exists: $name"
+ fi
+
+ local playlist_id
+ playlist_id="playlist-$(hash_value "$name-$(date +%s%3N)")"
+
+ jq --arg id "$playlist_id" --arg name "$name" --arg createdAt "$(date -Iseconds)" \
+ '. + [{id: $id, name: $name, createdAt: $createdAt, entryIds: []}]' \
+ "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+create_playlist() {
+ with_lock "$DATA_LOCK" _create_playlist_unlocked "$@"
+}
+
+_delete_playlist_unlocked() {
+ local playlist_id="${1-}"
+ require_cmd jq
+
+ if [[ -z "$playlist_id" ]]; then
+ die "Missing playlist id."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ jq --arg id "$playlist_id" 'map(select(.id != $id))' "$PLAYLISTS_FILE" \
+ | json_write_raw "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+delete_playlist() {
+ with_lock "$DATA_LOCK" _delete_playlist_unlocked "$@"
+}
+
+_rename_playlist_unlocked() {
+ local playlist_id="${1-}"
+ local name="${2-}"
+ require_cmd jq
+
+ if [[ -z "$playlist_id" ]]; then
+ die "Missing playlist id."
+ fi
+
+ if [[ -z "$name" ]]; then
+ die "Missing playlist name."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ if playlist_name_exists_except "$playlist_id" "$name"; then
+ die "Playlist already exists: $name"
+ fi
+
+ jq --arg id "$playlist_id" --arg name "$name" \
+ 'map(if .id == $id then .name = $name else . end)' "$PLAYLISTS_FILE" \
+ | json_write_raw "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+rename_playlist() {
+ with_lock "$DATA_LOCK" _rename_playlist_unlocked "$@"
+}
+
+_playlist_add_unlocked() {
+ local playlist_id="${1-}"
+ local entry_id="${2-}"
+ require_cmd jq
+
+ if [[ -z "$playlist_id" || -z "$entry_id" ]]; then
+ die "Missing playlist or entry id."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ if playlist_contains_entry "$playlist_id" "$entry_id"; then
+ die "Playlist already contains entry: $entry_id in $playlist_id"
+ fi
+
+ jq --arg pid "$playlist_id" --arg eid "$entry_id" \
+ 'map(if .id == $pid then .entryIds = ((.entryIds // []) | if index($eid) then . else . + [$eid] end) else . end)' \
+ "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+playlist_add() {
+ with_lock "$DATA_LOCK" _playlist_add_unlocked "$@"
+}
+
+_playlist_remove_unlocked() {
+ local playlist_id="${1-}"
+ local entry_id="${2-}"
+ require_cmd jq
+
+ if [[ -z "$playlist_id" || -z "$entry_id" ]]; then
+ die "Missing playlist or entry id."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ if ! playlist_contains_entry "$playlist_id" "$entry_id"; then
+ die "Playlist entry not found: $entry_id in $playlist_id"
+ fi
+
+ jq --arg pid "$playlist_id" --arg eid "$entry_id" \
+ 'map(if .id == $pid then .entryIds = ((.entryIds // []) | map(select(. != $eid))) else . end)' \
+ "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+playlist_remove() {
+ with_lock "$DATA_LOCK" _playlist_remove_unlocked "$@"
+}
+
+_playlist_hide_local_from_saved_unlocked() {
+ local playlist_id="${1-}"
+ local playlist_name=""
+ local entry_ids_json="[]"
+ local affected_count=0
+ require_cmd jq
+
+ if [[ -z "$playlist_id" ]]; then
+ die "Missing playlist id."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ playlist_name="$(jq -r --arg id "$playlist_id" '(map(select(.id == $id)) | first | .name) // ""' "$PLAYLISTS_FILE")"
+ entry_ids_json="$(jq -c --arg id "$playlist_id" '(map(select(.id == $id)) | first | .entryIds) // []' "$PLAYLISTS_FILE")"
+
+ affected_count="$(
+ jq -r --argjson ids "$entry_ids_json" '
+ map(select(
+ (.provider // "") == "local"
+ and (. as $entry | ($ids | index($entry.id)) != null)
+ and (.isSaved != false)
+ )) | length
+ ' "$LIBRARY_FILE"
+ )"
+
+ jq --argjson ids "$entry_ids_json" '
+ map(
+ if (.provider // "") == "local" and (. as $entry | ($ids | index($entry.id)) != null)
+ then . + { isSaved: false }
+ else .
+ end
+ )
+ ' "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+
+ printf 'Marked %s local tracks in playlist "%s" as playlist-only.\n' "$affected_count" "$playlist_name"
+}
+
+playlist_hide_local_from_saved() {
+ with_lock "$DATA_LOCK" _playlist_hide_local_from_saved_unlocked "$@"
+}
+
+_local_entry_id_for_filepath() {
+ local filepath="${1-}"
+ local entry_id=""
+
+ if [[ -z "$filepath" ]]; then
+ printf '\n'
+ return 0
+ fi
+
+ entry_id="$(jq -r '.id // ""' <<< "$(local_file_metadata_json "$filepath")" 2>/dev/null || true)"
+ if [[ -z "$entry_id" ]]; then
+ entry_id="local-$(printf '%s' "$filepath" | sha256sum | cut -c1-16)"
+ fi
+
+ printf '%s\n' "$entry_id"
+}
+
+_cleanup_orphaned_playlist_only_entries_unlocked() {
+ local removed_ids_json="${1-[]}"
+
+ jq --argjson removed "$removed_ids_json" --slurpfile playlists "$PLAYLISTS_FILE" '
+ [($playlists[0] // [])[]?.entryIds[]?] as $activeIds
+ | map(
+ select(
+ (. as $entry | ($removed | index($entry.id)) == null)
+ or (.isSaved != false)
+ or (. as $entry | ($activeIds | index($entry.id)) != null)
+ )
+ )
+ ' "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+}
+
+_import_folder_playlist_unlocked() {
+ local folder_path="${1-}"
+ local playlist_name="${2-}"
+ local expanded_folder=""
+ local -a files=()
+ local -a entry_ids=()
+ local filepath=""
+ local entry_id=""
+ local imported_count=0
+ local playlist_id=""
+
+ require_cmd jq
+
+ if [[ -z "$folder_path" ]]; then
+ die "Missing folder path."
+ fi
+
+ expanded_folder="${folder_path/#\~/$HOME}"
+ if [[ ! -d "$expanded_folder" ]]; then
+ die "Folder not found: $folder_path"
+ fi
+
+ if [[ -z "$playlist_name" ]]; then
+ playlist_name="$(basename "$expanded_folder")"
+ fi
+
+ if [[ -z "$playlist_name" ]]; then
+ die "Could not derive playlist name from folder."
+ fi
+
+ while IFS= read -r filepath; do
+ [[ -n "$filepath" ]] && files+=("$filepath")
+ done < <(folder_audio_candidates "$expanded_folder")
+
+ if [[ ${#files[@]} -eq 0 ]]; then
+ die "No audio files found in: $folder_path"
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+ safe_read_json "$LIBRARY_FILE"
+
+ if playlist_name_exists "$playlist_name"; then
+ die "Playlist already exists: $playlist_name"
+ fi
+
+ playlist_id="playlist-$(hash_value "$playlist_name-$(date +%s%3N)")"
+
+ for filepath in "${files[@]}"; do
+ entry_id="$(_local_entry_id_for_filepath "$filepath")"
+ _save_entry_unlocked "$entry_id" "" "$filepath" "" 0 false >/dev/null
+ entry_ids+=("$entry_id")
+ ((imported_count+=1))
+ done
+
+ local entry_ids_json
+ entry_ids_json="$(printf '%s\n' "${entry_ids[@]}" | jq -R . | jq -sc '.')"
+
+ jq --arg id "$playlist_id" \
+ --arg name "$playlist_name" \
+ --arg createdAt "$(date -Iseconds)" \
+ --arg sourceFolder "$expanded_folder" \
+ --argjson entryIds "$entry_ids_json" \
+ '. + [{id: $id, name: $name, createdAt: $createdAt, updatedAt: $createdAt, sourceType: "folder", sourceFolder: $sourceFolder, entryIds: $entryIds}]' \
+ "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+
+ printf 'Imported %s tracks into playlist \"%s\".\n' "$imported_count" "$playlist_name"
+}
+
+import_folder_playlist() {
+ with_lock "$DATA_LOCK" _import_folder_playlist_unlocked "$@"
+}
+
+_sync_folder_playlist_unlocked() {
+ local playlist_id="${1-}"
+ local playlist_name=""
+ local source_folder=""
+ local old_entry_ids_json="[]"
+ local new_entry_ids_json="[]"
+ local removed_entry_ids_json="[]"
+ local -a files=()
+ local -a new_entry_ids=()
+ local filepath=""
+ local entry_id=""
+ local synced_count=0
+ local added_count=0
+ local removed_count=0
+ declare -A old_ids_map=()
+ declare -A new_ids_map=()
+ require_cmd jq
+
+ if [[ -z "$playlist_id" ]]; then
+ die "Missing playlist id."
+ fi
+
+ safe_read_json "$PLAYLISTS_FILE"
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! playlist_exists "$playlist_id"; then
+ die "Playlist not found: $playlist_id"
+ fi
+
+ playlist_name="$(jq -r --arg id "$playlist_id" '(map(select(.id == $id)) | first | .name) // ""' "$PLAYLISTS_FILE")"
+ source_folder="$(jq -r --arg id "$playlist_id" '(map(select(.id == $id)) | first | .sourceFolder) // ""' "$PLAYLISTS_FILE")"
+ old_entry_ids_json="$(jq -c --arg id "$playlist_id" '(map(select(.id == $id)) | first | .entryIds) // []' "$PLAYLISTS_FILE")"
+
+ if [[ -z "$source_folder" ]]; then
+ die "Playlist is not linked to an imported folder: $playlist_name"
+ fi
+
+ if [[ ! -d "$source_folder" ]]; then
+ die "Folder no longer exists for playlist \"$playlist_name\": $source_folder"
+ fi
+
+ while IFS= read -r filepath; do
+ [[ -n "$filepath" ]] && files+=("$filepath")
+ done < <(folder_audio_candidates "$source_folder")
+
+ for filepath in "${files[@]}"; do
+ entry_id="$(_local_entry_id_for_filepath "$filepath")"
+ _save_entry_unlocked "$entry_id" "" "$filepath" "" 0 false >/dev/null
+ new_entry_ids+=("$entry_id")
+ new_ids_map["$entry_id"]=1
+ ((synced_count+=1))
+ done
+
+ while IFS= read -r entry_id; do
+ [[ -n "$entry_id" ]] && old_ids_map["$entry_id"]=1
+ done < <(jq -r '.[]' <<< "$old_entry_ids_json")
+
+ for entry_id in "${new_entry_ids[@]}"; do
+ if [[ -z "${old_ids_map[$entry_id]+x}" ]]; then
+ ((added_count+=1))
+ fi
+ done
+
+ while IFS= read -r entry_id; do
+ if [[ -n "$entry_id" && -z "${new_ids_map[$entry_id]+x}" ]]; then
+ ((removed_count+=1))
+ fi
+ done < <(jq -r '.[]' <<< "$old_entry_ids_json")
+
+ new_entry_ids_json="$(printf '%s\n' "${new_entry_ids[@]}" | jq -R . | jq -sc '.')"
+ removed_entry_ids_json="$(jq -cn --argjson old "$old_entry_ids_json" --argjson current "$new_entry_ids_json" '$old - $current')"
+
+ jq --arg id "$playlist_id" \
+ --arg sourceFolder "$source_folder" \
+ --arg updatedAt "$(date -Iseconds)" \
+ --argjson entryIds "$new_entry_ids_json" \
+ 'map(
+ if .id == $id
+ then .entryIds = $entryIds
+ | .sourceType = "folder"
+ | .sourceFolder = $sourceFolder
+ | .updatedAt = $updatedAt
+ else .
+ end
+ )' "$PLAYLISTS_FILE" | json_write_raw "$PLAYLISTS_FILE"
+
+ _cleanup_orphaned_playlist_only_entries_unlocked "$removed_entry_ids_json"
+
+ printf 'Synced playlist \"%s\": %s tracks, %s added, %s removed.\n' \
+ "$playlist_name" "$synced_count" "$added_count" "$removed_count"
+}
+
+sync_folder_playlist() {
+ with_lock "$DATA_LOCK" _sync_folder_playlist_unlocked "$@"
+}
+
+list_playlists() {
+ with_lock "$DATA_LOCK" _list_playlists_unlocked
+}
+
+library_list() {
+ with_lock "$DATA_LOCK" _library_list_unlocked
+}
+
+_list_playlists_unlocked() {
+ safe_read_json "$PLAYLISTS_FILE"
+ cat "$PLAYLISTS_FILE"
+}
+
+_library_list_unlocked() {
+ safe_read_json "$LIBRARY_FILE"
+ cat "$LIBRARY_FILE"
+}
diff --git a/.config/noctalia/plugins/music-search/lib/process.sh b/.config/noctalia/plugins/music-search/lib/process.sh
new file mode 100644
index 0000000..815853f
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/process.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+# process.sh — process management, hashing, cleanup
+
+library_entry_exists() {
+ local entry_id="$1"
+ jq -e --arg id "$entry_id" 'any(.[]?; .id == $id)' "$LIBRARY_FILE" >/dev/null
+}
+
+playlist_exists() {
+ local playlist_id="$1"
+ jq -e --arg id "$playlist_id" 'any(.[]?; .id == $id)' "$PLAYLISTS_FILE" >/dev/null
+}
+
+playlist_name_exists() {
+ local playlist_name="$1"
+ local normalized_target="${playlist_name,,}"
+ local existing_name=""
+
+ while IFS= read -r existing_name; do
+ if [[ "${existing_name,,}" == "$normalized_target" ]]; then
+ return 0
+ fi
+ done < <(jq -r '.[]?.name // empty' "$PLAYLISTS_FILE")
+
+ return 1
+}
+
+playlist_contains_entry() {
+ local playlist_id="$1"
+ local entry_id="$2"
+ jq -e \
+ --arg pid "$playlist_id" \
+ --arg eid "$entry_id" \
+ 'any(.[]?; .id == $pid and any((.entryIds // [])[]?; . == $eid))' \
+ "$PLAYLISTS_FILE" >/dev/null
+}
+
+playlist_name_exists_except() {
+ local playlist_id="$1"
+ local playlist_name="$2"
+ local normalized_target="${playlist_name,,}"
+ local existing_id=""
+ local existing_name=""
+
+ while IFS=$'\t' read -r existing_id existing_name; do
+ if [[ "$existing_id" == "$playlist_id" ]]; then
+ continue
+ fi
+ if [[ "${existing_name,,}" == "$normalized_target" ]]; then
+ return 0
+ fi
+ done < <(jq -r '.[]? | [.id // "", .name // ""] | @tsv' "$PLAYLISTS_FILE")
+
+ return 1
+}
+
+read_pid() {
+ if [[ -f "$PID_FILE" ]]; then
+ tr -d '\n' < "$PID_FILE" 2>/dev/null || true
+ fi
+}
+
+is_running_pid() {
+ local pid="${1-}"
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
+}
+
+terminate_pid() {
+ local pid="${1-}"
+ local pgid=""
+
+ if [[ -z "$pid" ]]; then
+ return 0
+ fi
+
+ pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
+ if [[ -n "$pgid" && "$pgid" == "$pid" ]]; then
+ kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
+ else
+ kill "$pid" 2>/dev/null || true
+ fi
+}
+
+force_terminate_pid() {
+ local pid="${1-}"
+ local pgid=""
+
+ if [[ -z "$pid" ]]; then
+ return 0
+ fi
+
+ pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
+ if [[ -n "$pgid" && "$pgid" == "$pid" ]]; then
+ kill -9 -- "-$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
+ else
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+}
+
+cleanup_runtime_cache() {
+ rm -f "${DOWNLOAD_BASENAME}".* "$LOG_FILE" "$SOCKET_FILE"
+}
+
+hash_value() {
+ local value="$1"
+ printf '%s' "$value" | sha256sum | cut -c1-16
+}
+
+_stop_existing_unlocked() {
+ safe_read_json "$STATE_FILE"
+
+ local pid
+ pid="$(read_pid)"
+
+ if is_running_pid "$pid"; then
+ terminate_pid "$pid"
+
+ for _ in $(seq 1 20); do
+ if ! is_running_pid "$pid"; then
+ break
+ fi
+ sleep 0.1
+ done
+
+ if is_running_pid "$pid"; then
+ force_terminate_pid "$pid"
+ fi
+ fi
+
+ rm -f "$PID_FILE"
+ cleanup_runtime_cache
+
+ _write_state_unlocked \
+ false \
+ "$(state_field '.id')" \
+ "$(state_field '.title')" \
+ "$(state_field '.url')" \
+ "$(state_field '.uploader')" \
+ "$(state_field '.duration')" \
+ "$(current_state_speed)" \
+ 0 \
+ "" \
+ "stopped"
+}
+
+_stop_existing_and_emit_unlocked() {
+ _stop_existing_unlocked "$@"
+ _emit_state_unlocked
+}
+
+stop_existing() {
+ _stop_existing_and_emit_unlocked "$@"
+}
diff --git a/.config/noctalia/plugins/music-search/lib/queue.sh b/.config/noctalia/plugins/music-search/lib/queue.sh
new file mode 100644
index 0000000..926605e
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/queue.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# queue.sh — persistent playback queue storage
+
+_list_queue_unlocked() {
+ safe_read_json "$QUEUE_FILE"
+ cat "$QUEUE_FILE"
+}
+
+list_queue() {
+ with_lock "$QUEUE_LOCK" _list_queue_unlocked
+}
+
+_queue_enqueue_unlocked() {
+ local entry_id="${1-}"
+ local title="${2-}"
+ local url="${3-}"
+ local uploader="${4-}"
+ local duration="${5-0}"
+ local is_saved="${6-true}"
+ require_cmd jq
+
+ if [[ -z "$url" ]]; then
+ die "Missing queue URL."
+ fi
+
+ safe_read_json "$QUEUE_FILE"
+
+ if [[ -z "$entry_id" ]]; then
+ entry_id="queue-$(hash_value "$url")"
+ fi
+
+ if [[ -z "$title" ]]; then
+ title="Untitled"
+ fi
+
+ jq \
+ --arg id "$entry_id" \
+ --arg title "$title" \
+ --arg url "$url" \
+ --arg uploader "$uploader" \
+ --argjson duration "${duration:-0}" \
+ --argjson isSaved "${is_saved:-true}" \
+ --arg queuedAt "$(date -Iseconds)" \
+ 'map(select(.id != $id and .url != $url)) + [{
+ id: $id,
+ title: $title,
+ url: $url,
+ uploader: $uploader,
+ duration: $duration,
+ isSaved: $isSaved,
+ queuedAt: $queuedAt
+ }]' "$QUEUE_FILE" | json_write_raw "$QUEUE_FILE"
+
+ _list_queue_unlocked
+}
+
+queue_enqueue() {
+ with_lock "$QUEUE_LOCK" _queue_enqueue_unlocked "$@"
+}
+
+_queue_remove_unlocked() {
+ local entry_id="${1-}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing queue entry id."
+ fi
+
+ safe_read_json "$QUEUE_FILE"
+ jq --arg id "$entry_id" 'map(select(.id != $id))' "$QUEUE_FILE" | json_write_raw "$QUEUE_FILE"
+ _list_queue_unlocked
+}
+
+queue_remove() {
+ with_lock "$QUEUE_LOCK" _queue_remove_unlocked "$@"
+}
+
+_queue_clear_unlocked() {
+ printf '[]\n' | json_write_raw "$QUEUE_FILE"
+ _list_queue_unlocked
+}
+
+queue_clear() {
+ with_lock "$QUEUE_LOCK" _queue_clear_unlocked
+}
+
+_queue_load_library_unlocked() {
+ local library_file="${1-}"
+ local mode="${2-ordered}"
+ require_cmd jq
+
+ if [[ -z "$library_file" ]]; then
+ die "Missing library file path."
+ fi
+
+ if [[ ! -f "$library_file" ]]; then
+ die "music-search library file not found."
+ fi
+
+ local lines_file
+ lines_file="$(mktemp)"
+
+ jq -c --arg queuedAt "$(date -Iseconds)" '
+ if type != "array" then
+ empty
+ else
+ .[]
+ | select(if .isSaved == null then true else .isSaved end)
+ | select((.url // "") != "")
+ | {
+ id: (.id // .url // ""),
+ title: (.title // "Untitled"),
+ url: (.url // ""),
+ uploader: (.uploader // ""),
+ duration: (.duration // 0),
+ isSaved: true,
+ queuedAt: (.queuedAt // $queuedAt)
+ }
+ end
+ ' "$library_file" > "$lines_file"
+
+ if [[ "$mode" == "shuffle" && -s "$lines_file" ]]; then
+ require_cmd shuf
+ shuf "$lines_file" | jq -s '.' | json_write_raw "$QUEUE_FILE"
+ elif [[ -s "$lines_file" ]]; then
+ jq -s '.' "$lines_file" | json_write_raw "$QUEUE_FILE"
+ else
+ printf '[]\n' | json_write_raw "$QUEUE_FILE"
+ fi
+
+ rm -f "$lines_file"
+ _list_queue_unlocked
+}
+
+queue_load_library() {
+ with_lock "$QUEUE_LOCK" _queue_load_library_unlocked "$@"
+}
+
+_queue_peek_unlocked() {
+ safe_read_json "$QUEUE_FILE"
+ jq -c '.[0] // {}' "$QUEUE_FILE"
+}
+
+queue_peek() {
+ with_lock "$QUEUE_LOCK" _queue_peek_unlocked
+}
diff --git a/.config/noctalia/plugins/music-search/lib/settings.sh b/.config/noctalia/plugins/music-search/lib/settings.sh
new file mode 100644
index 0000000..231553a
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/settings.sh
@@ -0,0 +1,322 @@
+#!/usr/bin/env bash
+# settings.sh — provider, search, tags, ratings, sort
+
+get_provider() {
+ with_lock "$SETTINGS_LOCK" _get_provider_unlocked
+}
+
+_get_provider_unlocked() {
+ safe_read_json "$SETTINGS_FILE"
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ local provider
+ provider="$(jq -r '.activeProvider // "youtube"' "$SETTINGS_FILE" 2>/dev/null || true)"
+ printf '%s\n' "${provider:-youtube}"
+ return 0
+ fi
+ printf 'youtube\n'
+}
+
+_set_provider_unlocked() {
+ local provider="${1-youtube}"
+ case "$provider" in
+ youtube|soundcloud|local) ;;
+ *)
+ die "Unknown provider: $provider. Valid: youtube, soundcloud, local"
+ ;;
+ esac
+
+ safe_read_json "$SETTINGS_FILE"
+
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ jq --arg p "$provider" '.activeProvider = $p' "$SETTINGS_FILE" \
+ | json_write_raw "$SETTINGS_FILE"
+ else
+ jq -nc --arg p "$provider" '{activeProvider: $p}' \
+ | json_write_raw "$SETTINGS_FILE"
+ fi
+ cat "$SETTINGS_FILE"
+}
+
+set_provider() {
+ with_lock "$SETTINGS_LOCK" _set_provider_unlocked "$@"
+}
+
+search_youtube() {
+ local query="${1-}"
+ local -a yt_args=(yt-dlp --ignore-config --flat-playlist --dump-single-json)
+ local extractor_arg
+ extractor_arg="$(yt_extractor_args)"
+ [[ -n "$extractor_arg" ]] && yt_args+=("$extractor_arg")
+ yt_args+=("ytsearch15:${query}")
+ "${yt_args[@]}" 2>/dev/null \
+ | jq -c '[.entries[]? | {
+ id: (.id // ""),
+ title: (.title // "Untitled"),
+ url: ("https://www.youtube.com/watch?v=" + (.id // "")),
+ uploader: (.channel // .uploader // ""),
+ duration: (.duration // 0)
+ } | select(.id != "")]'
+}
+
+search_soundcloud() {
+ local query="${1-}"
+ yt-dlp --ignore-config --flat-playlist --dump-single-json "scsearch15:${query}" 2>/dev/null \
+ | jq -c '[.entries[]? | {
+ id: (.id // .display_id // ""),
+ title: (.title // "Untitled"),
+ url: (.url // .webpage_url // ""),
+ uploader: (.uploader // .channel // ""),
+ duration: (.duration // 0)
+ } | select(.url != "")]'
+}
+
+search_local() {
+ local query="${1-}"
+ require_cmd jq
+
+ if [[ ! -d "$LOCAL_MUSIC_DIR" ]]; then
+ printf '[]\n'
+ return 0
+ fi
+
+ local -a result_objects=()
+ local match_count=0
+ local query_lower
+ query_lower="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')"
+
+ while IFS= read -r filepath; do
+ local filename base_name display_name guessed_title guessed_artist searchable
+ filename="$(basename "$filepath")"
+ base_name="${filename%.*}"
+ display_name="$(strip_known_media_id_suffix "$base_name")"
+ guessed_title="$display_name"
+ guessed_artist=""
+
+ if [[ "$display_name" == *" - "* ]]; then
+ guessed_artist="${display_name%% - *}"
+ guessed_title="${display_name#* - }"
+ elif [[ "$display_name" == *" — "* ]]; then
+ guessed_artist="${display_name%% — *}"
+ guessed_title="${display_name#* — }"
+ elif [[ "$display_name" == *" – "* ]]; then
+ guessed_artist="${display_name%% – *}"
+ guessed_title="${display_name#* – }"
+ fi
+
+ searchable="$(printf '%s %s %s %s' "$filename" "$display_name" "$guessed_artist" "$guessed_title" | tr '[:upper:]' '[:lower:]')"
+
+ if [[ "$searchable" == *"$query_lower"* ]]; then
+ result_objects+=("$(jq -nc \
+ --arg id "local-$(printf '%s' "$filepath" | sha256sum | cut -c1-16)" \
+ --arg title "${guessed_title:-$display_name}" \
+ --arg url "$filepath" \
+ --arg uploader "${guessed_artist:-Local file}" \
+ --arg localPath "$filepath" \
+ --arg provider "local" \
+ '{
+ id: $id,
+ title: $title,
+ url: $url,
+ uploader: $uploader,
+ duration: 0,
+ localPath: $localPath,
+ provider: $provider
+ }')")
+ ((match_count+=1))
+ fi
+
+ if [[ "$match_count" -ge 15 ]]; then
+ break
+ fi
+ done < <(local_audio_candidates "$query")
+
+ if [[ ${#result_objects[@]} -eq 0 ]]; then
+ printf '[]\n'
+ else
+ printf '%s\n' "${result_objects[@]}" | jq -sc '.'
+ fi
+}
+
+_tag_entry_unlocked() {
+ local entry_id="${1-}"
+ local tag="${2-}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing entry id."
+ fi
+ if [[ -z "$tag" ]]; then
+ die "Missing tag."
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ jq --arg id "$entry_id" --arg tag "$tag" \
+ 'map(if .id == $id then .tags = ((.tags // []) | if index($tag) then . else . + [$tag] end) else . end)' \
+ "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+ cat "$LIBRARY_FILE"
+}
+
+tag_entry() {
+ with_lock "$DATA_LOCK" _tag_entry_unlocked "$@"
+}
+
+_untag_entry_unlocked() {
+ local entry_id="${1-}"
+ local tag="${2-}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing entry id."
+ fi
+ if [[ -z "$tag" ]]; then
+ die "Missing tag."
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ jq --arg id "$entry_id" --arg tag "$tag" \
+ 'map(if .id == $id then .tags = ((.tags // []) | map(select(. != $tag))) else . end)' \
+ "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+ cat "$LIBRARY_FILE"
+}
+
+untag_entry() {
+ with_lock "$DATA_LOCK" _untag_entry_unlocked "$@"
+}
+
+_rate_entry_unlocked() {
+ local entry_id="${1-}"
+ local rating="${2-0}"
+ require_cmd jq
+
+ if [[ -z "$entry_id" ]]; then
+ die "Missing entry id."
+ fi
+
+ safe_read_json "$LIBRARY_FILE"
+
+ if ! library_entry_exists "$entry_id"; then
+ die "Library entry not found: $entry_id"
+ fi
+
+ if [[ "$rating" -lt 0 ]] 2>/dev/null || [[ "$rating" -gt 5 ]] 2>/dev/null; then
+ die "Rating must be 0-5."
+ fi
+
+ jq --arg id "$entry_id" --argjson rating "${rating:-0}" \
+ 'map(if .id == $id then .rating = $rating else . end)' \
+ "$LIBRARY_FILE" | json_write_raw "$LIBRARY_FILE"
+ cat "$LIBRARY_FILE"
+}
+
+rate_entry() {
+ with_lock "$DATA_LOCK" _rate_entry_unlocked "$@"
+}
+
+_set_sort_unlocked() {
+ local sort_by="${1-date}"
+ case "$sort_by" in
+ date|title|duration|rating) ;;
+ *)
+ die "Unknown sort: $sort_by. Valid: date, title, duration, rating"
+ ;;
+ esac
+
+ safe_read_json "$SETTINGS_FILE"
+
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ jq --arg s "$sort_by" '.sortBy = $s' "$SETTINGS_FILE" \
+ | json_write_raw "$SETTINGS_FILE"
+ else
+ jq -nc --arg s "$sort_by" '{sortBy: $s}' \
+ | json_write_raw "$SETTINGS_FILE"
+ fi
+ cat "$SETTINGS_FILE"
+}
+
+set_sort() {
+ with_lock "$SETTINGS_LOCK" _set_sort_unlocked "$@"
+}
+
+_set_download_dir_unlocked() {
+ local download_dir="${1-}"
+
+ if [[ -z "$download_dir" ]]; then
+ die "Missing download directory."
+ fi
+
+ mkdir -p "$download_dir" || die "Could not create download directory: $download_dir"
+ safe_read_json "$SETTINGS_FILE"
+
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ jq --arg path "$download_dir" '.downloadDirectory = $path' "$SETTINGS_FILE" \
+ | json_write_raw "$SETTINGS_FILE"
+ else
+ jq -nc --arg path "$download_dir" '{downloadDirectory: $path}' \
+ | json_write_raw "$SETTINGS_FILE"
+ fi
+ cat "$SETTINGS_FILE"
+}
+
+set_download_dir() {
+ with_lock "$SETTINGS_LOCK" _set_download_dir_unlocked "$@"
+}
+
+_set_cache_size_unlocked() {
+ local max_mb="${1-0}"
+
+ if [[ ! "$max_mb" =~ ^[0-9]+$ ]]; then
+ die "Cache size must be a non-negative number."
+ fi
+
+ safe_read_json "$SETTINGS_FILE"
+
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ jq --argjson size "$max_mb" '.downloadCacheMaxMb = $size' "$SETTINGS_FILE" \
+ | json_write_raw "$SETTINGS_FILE"
+ else
+ jq -nc --argjson size "$max_mb" '{downloadCacheMaxMb: $size}' \
+ | json_write_raw "$SETTINGS_FILE"
+ fi
+
+ prune_download_cache
+ cat "$SETTINGS_FILE"
+}
+
+set_cache_size() {
+ with_lock "$SETTINGS_LOCK" _set_cache_size_unlocked "$@"
+}
+
+_set_yt_player_client_unlocked() {
+ local client="${1-android}"
+ case "$client" in
+ android|web|default) ;;
+ *)
+ die "Unknown YouTube player client: $client. Valid: android, web, default"
+ ;;
+ esac
+
+ safe_read_json "$SETTINGS_FILE"
+
+ if [[ -f "$SETTINGS_FILE" ]]; then
+ jq --arg c "$client" '.ytPlayerClient = $c' "$SETTINGS_FILE" \
+ | json_write_raw "$SETTINGS_FILE"
+ else
+ jq -nc --arg c "$client" '{ytPlayerClient: $c}' \
+ | json_write_raw "$SETTINGS_FILE"
+ fi
+ cat "$SETTINGS_FILE"
+}
+
+set_yt_player_client() {
+ with_lock "$SETTINGS_LOCK" _set_yt_player_client_unlocked "$@"
+}
diff --git a/.config/noctalia/plugins/music-search/lib/state.sh b/.config/noctalia/plugins/music-search/lib/state.sh
new file mode 100644
index 0000000..f554786
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/lib/state.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# state.sh — state file read/write
+
+_write_state_unlocked() {
+ local is_playing="$1"
+ local entry_id="$2"
+ local title="$3"
+ local url="$4"
+ local uploader="$5"
+ local duration="$6"
+ local speed="${7-1}"
+ local pid="$8"
+ local error="${9-}"
+ local end_reason="${10-}"
+ local is_paused="${11:-false}"
+ local updated_at
+ updated_at="$(date +%s%3N)"
+
+ jq -nc \
+ --argjson isPlaying "$is_playing" \
+ --argjson isPaused "$is_paused" \
+ --arg id "$entry_id" \
+ --arg title "$title" \
+ --arg url "$url" \
+ --arg uploader "$uploader" \
+ --argjson duration "${duration:-0}" \
+ --argjson speed "${speed:-1}" \
+ --argjson pid "${pid:-0}" \
+ --arg error "$error" \
+ --arg endReason "$end_reason" \
+ --arg updatedAt "$updated_at" \
+ '{isPlaying:$isPlaying,isPaused:$isPaused,id:$id,title:$title,url:$url,uploader:$uploader,duration:$duration,speed:$speed,pid:$pid,error:$error,endReason:$endReason,updatedAt:$updatedAt}' \
+ | json_write_raw "$STATE_FILE"
+}
+
+write_state() {
+ _write_state_unlocked "$@"
+}
+
+_emit_state_unlocked() {
+ safe_read_json "$STATE_FILE"
+ cat "$STATE_FILE"
+}
+
+emit_state() {
+ _emit_state_unlocked
+}
+
+state_field() {
+ local field="$1"
+ jq -r "$field" "$STATE_FILE"
+}
+
+current_state_speed() {
+ safe_read_json "$STATE_FILE"
+ local speed
+ speed="$(jq -r '(.speed // 1) | tonumber? // 1' "$STATE_FILE" 2>/dev/null || printf '1')"
+ if [[ -z "$speed" || "$speed" == "null" ]]; then
+ printf '1\n'
+ return 0
+ fi
+ printf '%s\n' "$speed"
+}
+
+_write_current_state_error_unlocked() {
+ local error_message="$1"
+ safe_read_json "$STATE_FILE"
+ _write_state_unlocked \
+ "$(state_field '.isPlaying')" \
+ "$(state_field '.id')" \
+ "$(state_field '.title')" \
+ "$(state_field '.url')" \
+ "$(state_field '.uploader')" \
+ "$(state_field '.duration')" \
+ "$(state_field '.speed // 1')" \
+ "$(state_field '.pid')" \
+ "$error_message" \
+ "$(state_field '.endReason // ""')" \
+ "$(state_field '.isPaused // false')"
+}
+
+_write_current_state_error_and_emit_unlocked() {
+ _write_current_state_error_unlocked "$1"
+ _emit_state_unlocked
+}
+
+write_current_state_error() {
+ _write_current_state_error_unlocked "$@"
+}
+
+write_current_state_error_and_emit() {
+ _write_current_state_error_and_emit_unlocked "$@"
+}
diff --git a/.config/noctalia/plugins/music-search/manifest.json b/.config/noctalia/plugins/music-search/manifest.json
new file mode 100644
index 0000000..1fcf8aa
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/manifest.json
@@ -0,0 +1,65 @@
+{
+ "id": "music-search",
+ "name": "Music Search",
+ "version": "1.2.2",
+ "minNoctaliaVersion": "4.6.6",
+ "author": "kevichi7",
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "Search YouTube, play audio in the background with mpv, and save favorites into a persistent library.",
+ "tags": [
+ "Bar",
+ "Panel",
+ "Launcher",
+ "Audio",
+ "Music"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "panel": "Panel.qml",
+ "launcherProvider": "LauncherProvider.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "commandPrefix": "music-search",
+ "defaultSettings": {
+ "previewMetadataMode": "always",
+ "showUploaderMetadata": true,
+ "showAlbumMetadata": true,
+ "showDurationMetadata": true,
+ "showRatingMetadata": true,
+ "showTagMetadata": true,
+ "showPlayStatsMetadata": true,
+ "showStatusMetadata": true,
+ "showPreviewChips": true,
+ "previewThumbnailSize": "comfortable",
+ "showHomeRecent": true,
+ "showHomeTop": true,
+ "showHomeTags": true,
+ "showHomeArtists": true,
+ "showHomePlaylists": true,
+ "defaultPanelTab": "search",
+ "defaultPanelLibrarySection": "tracks",
+ "panelDensity": "balanced",
+ "showPanelHeader": true,
+ "showPanelNowPlaying": true,
+ "showPanelPlaybackProgress": true,
+ "showPanelProviderChips": true,
+ "showPanelRecentTracks": true,
+ "showPanelSearchHelper": true,
+ "showPanelPreview": true,
+ "previewPaneWidth": 0,
+ "showPanelUrlActions": true,
+ "showPanelSpeedControls": true,
+ "showPanelQueueControls": true,
+ "showPanelStatusBanner": true,
+ "showBarHoverTrackTitle": true,
+ "autoSaveMp3AfterPlayback": false,
+ "ytPlayerClient": "android"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/music-search/musicctl.sh b/.config/noctalia/plugins/music-search/musicctl.sh
new file mode 100755
index 0000000..c8cf645
--- /dev/null
+++ b/.config/noctalia/plugins/music-search/musicctl.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+LIB_DIR="${SCRIPT_DIR}/lib"
+
+source "${LIB_DIR}/config.sh"
+source "${LIB_DIR}/state.sh"
+source "${LIB_DIR}/process.sh"
+source "${LIB_DIR}/mpv-ipc.sh"
+source "${LIB_DIR}/launch.sh"
+source "${LIB_DIR}/media-utils.sh"
+source "${LIB_DIR}/library.sh"
+source "${LIB_DIR}/playback.sh"
+source "${LIB_DIR}/settings.sh"
+source "${LIB_DIR}/playlists.sh"
+source "${LIB_DIR}/queue.sh"
+
+ensure_files
+
+command="${1-status}"
+shift || true
+
+case "$command" in
+ play)
+ play_track "${1-}" "${2-}" "${3-}" "${4-}" "${5-0}"
+ ;;
+ stop)
+ stop_existing
+ ;;
+ status)
+ current_status
+ ;;
+ search)
+ search_tracks "${1-}" "${2-}"
+ ;;
+ details)
+ details_for_url "${1-}"
+ ;;
+ save)
+ save_entry "${1-}" "${2-}" "${3-}" "${4-}" "${5-0}"
+ ;;
+ edit-metadata)
+ edit_metadata "${1-}" "${2-}" "${3-}"
+ ;;
+ save-url)
+ save_url "${1-}"
+ ;;
+ download-mp3)
+ download_mp3 "${1-}" "${2-}"
+ ;;
+ remove)
+ remove_entry "${1-}"
+ ;;
+ import-folder-playlist)
+ import_folder_playlist "${1-}" "${2-}"
+ ;;
+ sync-folder-playlist)
+ sync_folder_playlist "${1-}"
+ ;;
+ playlist-hide-local-from-saved)
+ playlist_hide_local_from_saved "${1-}"
+ ;;
+ library)
+ library_list
+ ;;
+ pause)
+ pause_playback
+ ;;
+ resume)
+ resume_playback
+ ;;
+ position)
+ position_query
+ ;;
+ get-speed)
+ speed_query
+ ;;
+ seek)
+ seek_absolute "${1-}"
+ ;;
+ speed)
+ set_speed "${1-}"
+ ;;
+ get-provider)
+ get_provider
+ ;;
+ set-provider)
+ set_provider "${1-youtube}"
+ ;;
+ tag)
+ tag_entry "${1-}" "${2-}"
+ ;;
+ untag)
+ untag_entry "${1-}" "${2-}"
+ ;;
+ rate)
+ rate_entry "${1-}" "${2-0}"
+ ;;
+ set-sort)
+ set_sort "${1-date}"
+ ;;
+ set-download-dir)
+ set_download_dir "${1-}"
+ ;;
+ set-cache-size)
+ set_cache_size "${1-0}"
+ ;;
+ set-yt-player-client)
+ set_yt_player_client "${1-android}"
+ ;;
+ create-playlist)
+ create_playlist "${1-}"
+ ;;
+ delete-playlist)
+ delete_playlist "${1-}"
+ ;;
+ rename-playlist)
+ rename_playlist "${1-}" "${2-}"
+ ;;
+ playlist-add)
+ playlist_add "${1-}" "${2-}"
+ ;;
+ playlist-remove)
+ playlist_remove "${1-}" "${2-}"
+ ;;
+ playlists)
+ list_playlists
+ ;;
+ queue-list)
+ list_queue
+ ;;
+ queue-enqueue)
+ queue_enqueue "${1-}" "${2-}" "${3-}" "${4-}" "${5-0}" "${6-true}"
+ ;;
+ queue-remove)
+ queue_remove "${1-}"
+ ;;
+ queue-clear)
+ queue_clear
+ ;;
+ queue-load-library)
+ queue_load_library "${1-}" "${2-ordered}"
+ ;;
+ queue-peek)
+ queue_peek
+ ;;
+ *)
+ die "Unknown command: $command"
+ ;;
+esac
diff --git a/.config/noctalia/plugins/polkit-agent/Main.qml b/.config/noctalia/plugins/polkit-agent/Main.qml
new file mode 100644
index 0000000..f4fca35
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/Main.qml
@@ -0,0 +1,54 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Polkit
+import qs.Commons
+
+Item {
+ id: root
+ property var pluginApi: null
+
+ PolkitAgent {
+ id: agent
+
+ onIsActiveChanged: {
+ if (isActive) {
+ openWindow()
+ } else {
+ closeWindow()
+ }
+ }
+ }
+
+ property var window: null
+
+ function openWindow() {
+ if (agent.flow === null) {
+ Logger.w("polkit-agent: Cannot open window, agent.flow is null");
+ return;
+ }
+ if (window === null) {
+ var component = Qt.createComponent("PolkitWindow.qml");
+ if (component.status === Component.Ready) {
+ window = component.createObject(root, {
+ flow: agent.flow,
+ pluginApi: root.pluginApi
+ });
+ if (window !== null) {
+ window.visible = true;
+ }
+ }
+ component.destroy();
+ } else {
+ window.flow = agent.flow;
+ window.pluginApi = root.pluginApi;
+ window.visible = true;
+ }
+ }
+
+ function closeWindow() {
+ if (window !== null) {
+ window.destroy();
+ window = null;
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/polkit-agent/PolkitWindow.qml b/.config/noctalia/plugins/polkit-agent/PolkitWindow.qml
new file mode 100644
index 0000000..5407bc2
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/PolkitWindow.qml
@@ -0,0 +1,259 @@
+import QtQuick.Layouts
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Polkit
+import Quickshell.Wayland
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+
+PanelWindow {
+ id: polkitWindow
+
+ property AuthFlow flow
+ property var pluginApi
+ property string resolvedMessage: ""
+ property var transientMatch: null
+
+ Connections {
+ target: flow
+ function onFailedChanged() {
+ if (flow && flow.failed) {
+ ToastService.showError(
+ pluginApi ? pluginApi.tr("error.failed.title") : "Authentication Failed",
+ pluginApi ? pluginApi.tr("error.failed.message") : "The password you entered was incorrect."
+ )
+ }
+ }
+ }
+
+ // Resolve transient service names when flow changes
+ onFlowChanged: {
+ resolveTransientServiceName(flow.message);
+ }
+
+ // Resolve transient service names (run-PID-random.service) to actual command
+ Process {
+ id: cmdLineProcess
+ command: ["cat", "/proc/0/cmdline"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: function() {
+ var args = this.text.split(String.fromCharCode(0)).filter(function(s) { return s.length > 0; });
+ Logger.i("polkit-agent: Process output length=" + this.text.length + ", args=" + args.length);
+ if (args.length > 0 && polkitWindow.transientMatch) {
+ var resolvedCmd = args.join(' ');
+ var isCommand = args.length > 1 || args[0].includes('/');
+ polkitWindow.resolvedMessage = polkitWindow.resolvedMessage.replace(polkitWindow.transientMatch[0], resolvedCmd);
+ if (isCommand) {
+ polkitWindow.resolvedMessage = polkitWindow.resolvedMessage.replace(/transient unit/i, 'run command');
+ }
+ Logger.i("polkit-agent: Resolved message: " + polkitWindow.resolvedMessage);
+ }
+ }
+ }
+ }
+
+ function resolveTransientServiceName(message) {
+ if (!message) return;
+ var match = message.match(/run-p?(\d+)-[^.]+\.service/);
+ if (!match) {
+ Logger.i("polkit-agent: No transient service pattern in message: " + message);
+ polkitWindow.resolvedMessage = message;
+ polkitWindow.transientMatch = null;
+ return;
+ }
+ var pid = match[1];
+ Logger.i("polkit-agent: Found transient service pattern, PID=" + pid + ", message=" + message);
+ polkitWindow.resolvedMessage = message;
+ polkitWindow.transientMatch = match;
+ cmdLineProcess.command = ["cat", "/proc/" + pid + "/cmdline"];
+ cmdLineProcess.running = true;
+ }
+
+ // Layer above everything else (critical system prompt)
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ readonly property real shadowPadding: Style.shadowBlurMax + Style.marginL
+
+ // Explicit size - include shadowPadding so the shadow isn't clipped at window corners
+ implicitWidth: 400 * Style.uiScaleRatio + shadowPadding * 2
+ implicitHeight: contentLayout.implicitHeight + (Style.marginL * 2) + shadowPadding * 2
+
+ color: "transparent"
+
+ Item {
+ id: contentContainer
+ anchors.fill: parent
+ anchors.margins: shadowPadding
+ focus: true
+
+ Keys.onPressed: function(event) {
+ if (!flow) return;
+
+ if (Keybinds.checkKey(event, "escape", Settings)) {
+ flow.cancelAuthenticationRequest();
+ event.accepted = true;
+ } else if (Keybinds.checkKey(event, "enter", Settings)) {
+ if (passwordInput.text !== "") {
+ flow.submit(passwordInput.text);
+ passwordInput.text = "";
+ }
+ event.accepted = true;
+ }
+ }
+
+
+ transform: Translate {
+ id: shakeTranslate
+ x: 0
+ }
+
+ // Error animation
+ SequentialAnimation {
+ id: errorShake
+ running: flow && flow.failed
+ loops: 1
+
+ NumberAnimation { target: shakeTranslate; property: "x"; from: 0; to: -10; duration: 50; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: shakeTranslate; property: "x"; from: -10; to: 10; duration: 50; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: shakeTranslate; property: "x"; from: 10; to: -10; duration: 50; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: shakeTranslate; property: "x"; from: -10; to: 10; duration: 50; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: shakeTranslate; property: "x"; from: 10; to: 0; duration: 50; easing.type: Easing.InOutQuad }
+ }
+
+ // Shadow effect (behind background)
+ NDropShadow {
+ anchors.fill: customBackground
+ source: customBackground
+ autoPaddingEnabled: true
+ z: -1
+ }
+
+ Rectangle {
+ id: customBackground
+ anchors.fill: parent
+ radius: Style.radiusL
+ color: Qt.alpha(Color.mSurface, 0.95)
+ border.color: (flow && (flow.failed || flow.supplementaryIsError)) ? Color.mError : Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on border.color {
+ ColorAnimation { duration: Style.animationFast }
+ }
+ }
+
+ ColumnLayout {
+ id: contentLayout
+ anchors.centerIn: parent
+ width: parent.width - (Style.marginL * 2)
+ spacing: Style.marginM
+
+ // Header with Icon
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NImageRounded {
+ Layout.preferredWidth: Style.fontSizeXXL * 2
+ Layout.preferredHeight: Style.fontSizeXXL * 2
+ imagePath: Settings.preprocessPath(Settings.data.general.avatarImage) || ((flow && flow.iconName) ? Quickshell.iconPath(flow.iconName) : "")
+ fallbackIcon: "lock"
+ borderWidth: 0
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginXS
+
+ NText {
+ text: polkitWindow.resolvedMessage || (flow ? flow.message : (pluginApi ? pluginApi.tr("window.title") : "Authentication Required"))
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: flow ? flow.actionId : ""
+ pointSize: Style.fontSizeXS
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ visible: text !== ""
+ }
+ }
+ }
+
+ // Supplementary Message (Error or prompt)
+ NText {
+ visible: flow && flow.supplementaryMessage !== ""
+ text: flow ? flow.supplementaryMessage : ""
+ pointSize: Style.fontSizeS
+ color: (flow && flow.supplementaryIsError) ? Color.mError : Color.mOnSurfaceVariant
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ }
+
+ // Input Field
+ NTextInput {
+ id: passwordInput
+ Layout.fillWidth: true
+ placeholderText: flow ? flow.inputPrompt : (pluginApi ? pluginApi.tr("prompt.password") : "Password")
+ label: (flow && flow.isResponseRequired) ? (pluginApi ? pluginApi.tr("prompt.password") : "Password") : ""
+ inputItem.echoMode: (flow && !flow.responseVisible) ? TextInput.Password : TextInput.Normal
+ visible: flow && flow.isResponseRequired
+
+ onAccepted: {
+ if (flow) {
+ flow.submit(passwordInput.text)
+ passwordInput.text = ""
+ }
+ }
+ }
+
+ // Actions
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginS
+ spacing: Style.marginM
+
+ Item { Layout.fillWidth: true } // Spacer
+
+ NButton {
+ text: pluginApi ? pluginApi.tr("action.cancel") : "Cancel"
+ backgroundColor: Color.mSurfaceVariant
+ textColor: Color.mOnSurfaceVariant
+ outlined: false
+ onClicked: {
+ if (flow) flow.cancelAuthenticationRequest()
+ }
+ }
+
+ NButton {
+ text: pluginApi ? pluginApi.tr("action.authenticate") : "Authenticate"
+ backgroundColor: Color.mPrimary
+ textColor: Color.mOnPrimary
+ enabled: flow && flow.isResponseRequired
+ onClicked: {
+ if (flow) {
+ flow.submit(passwordInput.text)
+ passwordInput.text = ""
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Focus handling
+ Component.onCompleted: {
+ passwordInput.inputItem.forceActiveFocus()
+ }
+}
diff --git a/.config/noctalia/plugins/polkit-agent/README.md b/.config/noctalia/plugins/polkit-agent/README.md
new file mode 100644
index 0000000..2200fa6
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/README.md
@@ -0,0 +1,13 @@
+# Polkit Agent
+
+This plugin provides a Polkit authentication agent for Noctalia. It allows you to authenticate actions that require elevated privileges directly within the shell.
+
+
+
+## Important
+
+To use this plugin, you **must disable or uninstall your existing polkit authentication agent** (e.g., `polkit-gnome`, `polkit-kde-agent`, `lxpolkit`, etc).
+
+Having multiple polkit agents running simultaneously will cause conflicts and prevent this plugin from working correctly.
+
+> You may need to **restart your session or computer** after enabling this plugin for the changes to take effect and for the new agent to be registered properly.
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/de.json b/.config/noctalia/plugins/polkit-agent/i18n/de.json
new file mode 100644
index 0000000..d52d98c
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/de.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Authentifizierung erforderlich"
+ },
+ "prompt": {
+ "password": "Passwort"
+ },
+ "action": {
+ "cancel": "Abbrechen",
+ "authenticate": "Authentifizieren"
+ },
+ "error": {
+ "failed": {
+ "title": "Authentifizierung fehlgeschlagen",
+ "message": "Das eingegebene Passwort war falsch."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/en.json b/.config/noctalia/plugins/polkit-agent/i18n/en.json
new file mode 100644
index 0000000..8beb58c
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/en.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Authentication Required"
+ },
+ "prompt": {
+ "password": "Password"
+ },
+ "action": {
+ "cancel": "Cancel",
+ "authenticate": "Authenticate"
+ },
+ "error": {
+ "failed": {
+ "title": "Authentication Failed",
+ "message": "The password you entered was incorrect."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/es.json b/.config/noctalia/plugins/polkit-agent/i18n/es.json
new file mode 100644
index 0000000..e20df44
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/es.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Autenticación requerida"
+ },
+ "prompt": {
+ "password": "Contraseña"
+ },
+ "action": {
+ "cancel": "Cancelar",
+ "authenticate": "Autenticar"
+ },
+ "error": {
+ "failed": {
+ "title": "Autenticación fallida",
+ "message": "La contraseña introducida es incorrecta."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/fr.json b/.config/noctalia/plugins/polkit-agent/i18n/fr.json
new file mode 100644
index 0000000..20b0a1a
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/fr.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Authentification requise"
+ },
+ "prompt": {
+ "password": "Mot de passe"
+ },
+ "action": {
+ "cancel": "Annuler",
+ "authenticate": "S'authentifier"
+ },
+ "error": {
+ "failed": {
+ "title": "Authentification échouée",
+ "message": "Le mot de passe saisi est incorrect."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/hu.json b/.config/noctalia/plugins/polkit-agent/i18n/hu.json
new file mode 100644
index 0000000..7b7cc1d
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/hu.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "HitelesÃtés szükséges"
+ },
+ "prompt": {
+ "password": "Jelszó"
+ },
+ "action": {
+ "cancel": "Mégse",
+ "authenticate": "HitelesÃtés"
+ },
+ "error": {
+ "failed": {
+ "title": "HitelesÃtés sikertelen",
+ "message": "A megadott jelszó helytelen volt."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/ja.json b/.config/noctalia/plugins/polkit-agent/i18n/ja.json
new file mode 100644
index 0000000..a75d265
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/ja.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "èªè¨¼ãŒå¿…è¦ã§ã™"
+ },
+ "prompt": {
+ "password": "パスワード"
+ },
+ "action": {
+ "cancel": "ã‚ャンセル",
+ "authenticate": "èªè¨¼"
+ },
+ "error": {
+ "failed": {
+ "title": "èªè¨¼ã«å¤±æ•—ã—ã¾ã—ãŸ",
+ "message": "入力ã•れãŸãƒ‘ã‚¹ãƒ¯ãƒ¼ãƒ‰ãŒæ£ã—ãã‚りã¾ã›ã‚“。"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/ko-KR.json b/.config/noctalia/plugins/polkit-agent/i18n/ko-KR.json
new file mode 100644
index 0000000..8eaa4df
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/ko-KR.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "ì¸ì¦ í•„ìš”"
+ },
+ "prompt": {
+ "password": "비밀번호"
+ },
+ "action": {
+ "cancel": "취소",
+ "authenticate": "ì¸ì¦"
+ },
+ "error": {
+ "failed": {
+ "title": "ì¸ì¦ 실패",
+ "message": "ìž…ë ¥í•œ 비밀번호가 올바르지 않습니다."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/ku.json b/.config/noctalia/plugins/polkit-agent/i18n/ku.json
new file mode 100644
index 0000000..4459ce1
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/ku.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Nasname pêwîst e"
+ },
+ "prompt": {
+ "password": "Şîfre"
+ },
+ "action": {
+ "cancel": "Betal bike",
+ "authenticate": "Nasnameyê bipesend bike"
+ },
+ "error": {
+ "failed": {
+ "title": "Nasname têk çû",
+ "message": "Şîfreya ku hûn nivîsandin şaş e."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/nl.json b/.config/noctalia/plugins/polkit-agent/i18n/nl.json
new file mode 100644
index 0000000..bf80c09
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/nl.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Authenticatie vereist"
+ },
+ "prompt": {
+ "password": "Wachtwoord"
+ },
+ "action": {
+ "cancel": "Annuleren",
+ "authenticate": "Authenticeren"
+ },
+ "error": {
+ "failed": {
+ "title": "Authenticatie mislukt",
+ "message": "Het ingevoerde wachtwoord was onjuist."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/nn-HN.json b/.config/noctalia/plugins/polkit-agent/i18n/nn-HN.json
new file mode 100644
index 0000000..6bbaf0f
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/nn-HN.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Autentisering kreves"
+ },
+ "prompt": {
+ "password": "Passord"
+ },
+ "action": {
+ "cancel": "Avbryt",
+ "authenticate": "Autentiser"
+ },
+ "error": {
+ "failed": {
+ "title": "Autentisering mislyktes",
+ "message": "Passordet du skrev inn var feil."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/nn-NO.json b/.config/noctalia/plugins/polkit-agent/i18n/nn-NO.json
new file mode 100644
index 0000000..868c980
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/nn-NO.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Autentisering krevst"
+ },
+ "prompt": {
+ "password": "Passord"
+ },
+ "action": {
+ "cancel": "Avbryt",
+ "authenticate": "Autentiser"
+ },
+ "error": {
+ "failed": {
+ "title": "Autentisering mislukkast",
+ "message": "Passordet du skreiv inn var feil."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/pl.json b/.config/noctalia/plugins/polkit-agent/i18n/pl.json
new file mode 100644
index 0000000..66184ca
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/pl.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Wymagane uwierzytelnienie"
+ },
+ "prompt": {
+ "password": "Hasło"
+ },
+ "action": {
+ "cancel": "Anuluj",
+ "authenticate": "Uwierzytelnij"
+ },
+ "error": {
+ "failed": {
+ "title": "Uwierzytelnienie nie powiodło się",
+ "message": "Wprowadzone hasło jest nieprawidłowe."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/pt.json b/.config/noctalia/plugins/polkit-agent/i18n/pt.json
new file mode 100644
index 0000000..f4af699
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/pt.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Autenticação Necessária"
+ },
+ "prompt": {
+ "password": "Senha"
+ },
+ "action": {
+ "cancel": "Cancelar",
+ "authenticate": "Autenticar"
+ },
+ "error": {
+ "failed": {
+ "title": "Autenticação Falhou",
+ "message": "A senha introduzida está incorreta."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/ru.json b/.config/noctalia/plugins/polkit-agent/i18n/ru.json
new file mode 100644
index 0000000..d70e837
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/ru.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "ТребуетÑÑ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ"
+ },
+ "prompt": {
+ "password": "Пароль"
+ },
+ "action": {
+ "cancel": "Отмена",
+ "authenticate": "Ðутентифицировать"
+ },
+ "error": {
+ "failed": {
+ "title": "Ошибка аутентификации",
+ "message": "Введен неверный пароль."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/sv.json b/.config/noctalia/plugins/polkit-agent/i18n/sv.json
new file mode 100644
index 0000000..f7af9f1
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/sv.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Autentisering krävs"
+ },
+ "prompt": {
+ "password": "Lösenord"
+ },
+ "action": {
+ "cancel": "Avbryt",
+ "authenticate": "Autentisera"
+ },
+ "error": {
+ "failed": {
+ "title": "Autentisering misslyckades",
+ "message": "Det angivna lösenordet var felaktigt."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/tr.json b/.config/noctalia/plugins/polkit-agent/i18n/tr.json
new file mode 100644
index 0000000..1384ee1
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/tr.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Kimlik Doğrulaması Gerekli"
+ },
+ "prompt": {
+ "password": "Parola"
+ },
+ "action": {
+ "cancel": "İptal",
+ "authenticate": "DoÄŸrula"
+ },
+ "error": {
+ "failed": {
+ "title": "Kimlik Doğrulama Başarısız",
+ "message": "Girdiğiniz parola yanlış."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/uk-UA.json b/.config/noctalia/plugins/polkit-agent/i18n/uk-UA.json
new file mode 100644
index 0000000..24daa3c
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/uk-UA.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Потрібна аутентифікаціÑ"
+ },
+ "prompt": {
+ "password": "Пароль"
+ },
+ "action": {
+ "cancel": "СкаÑувати",
+ "authenticate": "Ðутентифікувати"
+ },
+ "error": {
+ "failed": {
+ "title": "Помилка аутентифікації",
+ "message": "Введений пароль неправильний."
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/vi.json b/.config/noctalia/plugins/polkit-agent/i18n/vi.json
new file mode 100644
index 0000000..4872e37
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/vi.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "Yêu cầu xác thực"
+ },
+ "prompt": {
+ "password": "Máºt khẩu"
+ },
+ "action": {
+ "cancel": "Há»§y",
+ "authenticate": "Xác thực"
+ },
+ "error": {
+ "failed": {
+ "title": "Xác thực thất bại",
+ "message": "Máºt khẩu bạn nháºp không đúng."
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/zh-CN.json b/.config/noctalia/plugins/polkit-agent/i18n/zh-CN.json
new file mode 100644
index 0000000..4b4cfdc
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/zh-CN.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "需è¦èº«ä»½éªŒè¯"
+ },
+ "prompt": {
+ "password": "密ç "
+ },
+ "action": {
+ "cancel": "å–æ¶ˆ",
+ "authenticate": "验è¯"
+ },
+ "error": {
+ "failed": {
+ "title": "身份验è¯å¤±è´¥",
+ "message": "您输入的密ç 䏿£ç¡®ã€‚"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/i18n/zh-TW.json b/.config/noctalia/plugins/polkit-agent/i18n/zh-TW.json
new file mode 100644
index 0000000..f739c9e
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/i18n/zh-TW.json
@@ -0,0 +1,18 @@
+{
+ "window": {
+ "title": "需è¦èº«ä»½é©—è‰"
+ },
+ "prompt": {
+ "password": "密碼"
+ },
+ "action": {
+ "cancel": "å–æ¶ˆ",
+ "authenticate": "é©—è‰"
+ },
+ "error": {
+ "failed": {
+ "title": "身份驗è‰å¤±æ•—",
+ "message": "æ‚¨è¼¸å…¥çš„å¯†ç¢¼ä¸æ£ç¢ºã€‚"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/polkit-agent/manifest.json b/.config/noctalia/plugins/polkit-agent/manifest.json
new file mode 100644
index 0000000..30eb5e2
--- /dev/null
+++ b/.config/noctalia/plugins/polkit-agent/manifest.json
@@ -0,0 +1,24 @@
+{
+ "id": "polkit-agent",
+ "name": "Polkit Agent",
+ "version": "1.0.6",
+ "minNoctaliaVersion": "4.4.3",
+ "author": "Noctalia Team ",
+ "official": true,
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "Provides a Polkit authentication agent.",
+ "tags": [
+ "System",
+ "Security"
+ ],
+ "entryPoints": {
+ "main": "Main.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {}
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/BarWidget.qml b/.config/noctalia/plugins/privacy-indicator/BarWidget.qml
new file mode 100644
index 0000000..bdebd41
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/BarWidget.qml
@@ -0,0 +1,175 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ // Bar positioning properties
+ readonly property string screenName: screen ? screen.name : ""
+ readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
+ readonly property bool isVertical: barPosition === "left" || barPosition === "right"
+ readonly property real barHeight: Style.getBarHeightForScreen(screenName)
+ readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
+ readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
+
+ // Access main instance for state
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ property bool micActive: mainInstance ? mainInstance.micActive : false
+ property bool camActive: mainInstance ? mainInstance.camActive : false
+ property bool scrActive: mainInstance ? mainInstance.scrActive : false
+ property var micApps: mainInstance ? mainInstance.micApps : []
+ property var camApps: mainInstance ? mainInstance.camApps : []
+ property var scrApps: mainInstance ? mainInstance.scrApps : []
+
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ property bool hideInactive: cfg.hideInactive ?? defaults.hideInactive ?? false
+ property bool enableToast: cfg.enableToast ?? defaults.enableToast ?? true
+ property bool removeMargins: cfg.removeMargins ?? defaults.removeMargins ?? false
+ property int iconSpacing: cfg.iconSpacing ?? defaults.iconSpacing ?? 4
+ property string activeColorKey: cfg.activeColor ?? defaults.activeColor ?? "primary"
+ property string inactiveColorKey: cfg.inactiveColor ?? defaults.inactiveColor ?? "none"
+
+ readonly property color activeColor: Color.resolveColorKey(activeColorKey)
+ readonly property color inactiveColor: inactiveColorKey === "none" ? Qt.alpha(Color.mOnSurfaceVariant, 0.3) : Color.resolveColorKey(inactiveColorKey)
+ readonly property color micColor: micActive ? activeColor : inactiveColor
+ readonly property color camColor: camActive ? activeColor : inactiveColor
+ readonly property color scrColor: scrActive ? activeColor : inactiveColor
+
+ readonly property bool isVisible: !hideInactive || micActive || camActive || scrActive
+
+ property real margins: removeMargins ? 0 : Style.marginM * 2
+
+ readonly property real contentWidth: isVertical ? Style.capsuleHeight : Math.round(layout.implicitWidth + margins)
+ readonly property real contentHeight: isVertical ? Math.round(layout.implicitHeight + margins) : Style.capsuleHeight
+
+ implicitWidth: contentWidth
+ implicitHeight: contentHeight
+
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.isVisible
+ opacity: root.isVisible ? 1.0 : 0.0
+
+ function buildTooltip() {
+ var parts = [];
+
+ if (micActive && micApps.length > 0) {
+ parts.push("Mic: " + micApps.join(", "));
+ }
+
+ if (camActive && camApps.length > 0) {
+ parts.push("Cam: " + camApps.join(", "));
+ }
+
+ if (scrActive && scrApps.length > 0) {
+ parts.push("Screen sharing: " + scrApps.join(", "));
+ }
+
+ return parts.length > 0 ? parts.join("\n") : "";
+ }
+
+ Rectangle {
+ id: visualCapsule
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+ width: root.contentWidth
+ height: root.contentHeight
+ radius: Style.radiusM
+ color: Style.capsuleColor
+ border.color: Style.capsuleBorderColor
+ border.width: Style.capsuleBorderWidth
+
+ Item {
+ id: layout
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ implicitWidth: iconsLayout.implicitWidth
+ implicitHeight: iconsLayout.implicitHeight
+
+ GridLayout {
+ id: iconsLayout
+
+ columns: root.isVertical ? 1 : 3
+ rows: root.isVertical ? 3 : 1
+
+ rowSpacing: root.iconSpacing
+ columnSpacing: root.iconSpacing
+
+ NIcon {
+ visible: micActive || !root.hideInactive
+ icon: micActive ? "microphone" : "microphone-off"
+ color: root.micColor
+ }
+ NIcon {
+ visible: camActive || !root.hideInactive
+ icon: camActive ? "camera" : "camera-off"
+ color: root.camColor
+ }
+ NIcon {
+ visible: scrActive || !root.hideInactive
+ icon: scrActive ? "screen-share" : "screen-share-off"
+ color: root.scrColor
+ }
+ }
+ }
+ }
+
+ NPopupContextMenu {
+ id: contextMenu
+
+ model: [
+ {
+ "label": pluginApi?.tr("menu.settings"),
+ "action": "settings",
+ "icon": "settings"
+ },
+ ]
+
+ onTriggered: function (action) {
+ contextMenu.close();
+ PanelService.closeContextMenu(screen);
+ if (action === "settings") {
+ BarService.openPluginSettings(root.screen, pluginApi.manifest);
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.RightButton | Qt.LeftButton
+ hoverEnabled: true
+
+ onClicked: function (mouse) {
+ if (mouse.button === Qt.RightButton) {
+ PanelService.showContextMenu(contextMenu, root, screen);
+ } else if (mouse.button === Qt.LeftButton) {
+ if (pluginApi) pluginApi.openPanel(root.screen, root);
+ }
+ }
+
+ onEntered: {
+ var tooltipText = buildTooltip();
+ if (tooltipText) {
+ TooltipService.show(root, tooltipText, BarService.getTooltipDirection());
+ }
+ }
+ onExited: TooltipService.hide()
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/Main.qml b/.config/noctalia/plugins/privacy-indicator/Main.qml
new file mode 100644
index 0000000..29d94a0
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/Main.qml
@@ -0,0 +1,316 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pipewire
+import qs.Commons
+import qs.Services.UI
+
+Item {
+ id: root
+ property var pluginApi: null
+
+ // --- Logic extracted from BarWidget.qml ---
+
+ property bool micActive: false
+ property bool camActive: false
+ property bool scrActive: false
+ property var micApps: []
+ property var camApps: []
+ property var scrApps: []
+
+ property var accessHistory: []
+
+ // Previous states for history tracking
+ property var _prevMicApps: []
+ property var _prevCamApps: []
+ property var _prevScrApps: []
+
+ // Get active color from settings or default
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+ property bool enableToast: cfg.enableToast ?? defaults.enableToast ?? true
+ property string activeColorKey: cfg.activeColor ?? defaults.activeColor ?? "primary"
+ property string micFilterRegex: cfg.micFilterRegex ?? defaults.micFilterRegex ?? ""
+ property string camFilterRegex: cfg.camFilterRegex ?? defaults.camFilterRegex ?? ""
+
+ PwObjectTracker {
+ objects: Pipewire.ready ? Pipewire.nodes.values : []
+ }
+
+ Process {
+ id: cameraDetectionProcess
+ running: false
+ command: ["sh", "-c", "for dev in /sys/class/video4linux/video*; do [ -e \"$dev/name\" ] && grep -qv 'Metadata' \"$dev/name\" && dev_name=$(basename \"$dev\") && find /proc/[0-9]*/fd -lname \"/dev/$dev_name\" 2>/dev/null; done | cut -d/ -f3 | xargs -r ps -o comm= -p | sort -u | tr '\\n' ',' | sed 's/,$//'"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var appsString = this.text.trim();
+ var apps = appsString.length > 0 ? appsString.split(',') : [];
+
+ var filterRegex = null;
+ if (root.camFilterRegex && root.camFilterRegex.length > 0) {
+ try {
+ filterRegex = new RegExp(root.camFilterRegex);
+ } catch (e) {
+ Logger.w("PrivacyIndicator: Invalid camFilterRegex:", root.camFilterRegex);
+ }
+ }
+
+ var appNames = [];
+ for (var i = 0; i < apps.length; i++) {
+ var appName = apps[i];
+ if (filterRegex && appName && filterRegex.test(appName)) continue;
+ if (appName && appNames.indexOf(appName) === -1) appNames.push(appName);
+ }
+
+ root.camApps = appNames;
+ root.camActive = appNames.length > 0;
+ }
+ }
+ }
+
+
+ Timer {
+ interval: 1000
+ repeat: true
+ running: true
+ triggeredOnStart: true
+ onTriggered: updatePrivacyState()
+ }
+
+ function hasNodeLinks(node, links) {
+ for (var i = 0; i < links.length; i++) {
+ var link = links[i];
+ if (link && (link.source === node || link.target === node)) return true;
+ }
+ return false;
+ }
+
+ function getAppName(node) {
+ return node.properties["application.name"] || node.nickname || node.name || "";
+ }
+
+ function updateMicrophoneState(nodes, links) {
+ var appNames = [];
+ var isActive = false;
+
+ var filterRegex = null;
+ if (root.micFilterRegex && root.micFilterRegex.length > 0) {
+ try {
+ filterRegex = new RegExp(root.micFilterRegex);
+ } catch (e) {
+ Logger.w("PrivacyIndicator: Invalid micFilterRegex:", root.micFilterRegex);
+ }
+ }
+
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ if (!node || !node.isStream || !node.audio || node.isSink) continue;
+ if (!hasNodeLinks(node, links) || !node.properties) continue;
+ var mediaClass = node.properties["media.class"] || "";
+ if (mediaClass === "Stream/Input/Audio") {
+ if (node.properties["stream.capture.sink"] === "true") continue;
+
+ var appName = getAppName(node);
+ if (filterRegex && appName && filterRegex.test(appName)) continue;
+
+ isActive = true;
+ if (appName && appNames.indexOf(appName) === -1) appNames.push(appName);
+ }
+ }
+ root.micActive = isActive;
+ root.micApps = appNames;
+ }
+
+ function updateCameraState() {
+ cameraDetectionProcess.running = true;
+ }
+
+ function isScreenShareNode(node) {
+ if (!node.properties) return false;
+ var mediaClass = node.properties["media.class"] || "";
+ if (mediaClass.indexOf("Audio") >= 0) return false;
+ if (mediaClass.indexOf("Video") === -1) return false;
+ var mediaName = (node.properties["media.name"] || "").toLowerCase();
+ if (mediaName.match(/^(xdph-streaming|gsr-default|game capture|screen|desktop|display|cast|webrtc|v4l2)/) ||
+ mediaName === "gsr-default_output" ||
+ mediaName.match(/screen-cast|screen-capture|desktop-capture|monitor-capture|window-capture|game-capture/i)) {
+ return true;
+ }
+ return false;
+ }
+
+ function updateScreenShareState(nodes, links) {
+ var appNames = [];
+ var isActive = false;
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ if (!node || !hasNodeLinks(node, links) || !node.properties) continue;
+ if (isScreenShareNode(node)) {
+ isActive = true;
+ var appName = getAppName(node);
+ if (appName && appNames.indexOf(appName) === -1) appNames.push(appName);
+ }
+ }
+ root.scrActive = isActive;
+ root.scrApps = appNames;
+ }
+
+ function updatePrivacyState() {
+ if (!Pipewire.ready) return;
+ var nodes = Pipewire.nodes.values || [];
+ var links = Pipewire.links.values || [];
+ updateMicrophoneState(nodes, links);
+ updateCameraState();
+ updateScreenShareState(nodes, links);
+ }
+
+ // --- History Persistence ---
+
+ property string stateFile: ""
+ property bool isLoaded: false
+
+ Component.onCompleted: {
+ // Setup state file path
+ Qt.callLater(() => {
+ if (typeof Settings !== 'undefined' && Settings.cacheDir) {
+ stateFile = Settings.cacheDir + "privacy-history.json";
+ historyFileView.path = stateFile;
+ }
+ });
+ }
+
+ FileView {
+ id: historyFileView
+ printErrors: false
+ watchChanges: false
+
+ adapter: JsonAdapter {
+ id: adapter
+ property var history: []
+ }
+
+ onLoaded: {
+ root.isLoaded = true;
+ if (adapter.history) {
+ // Restore history
+ root.accessHistory = adapter.history;
+ }
+ }
+
+ onLoadFailed: error => {
+ // If file doesn't exist (error 2), we are ready to save new data
+ if (error === 2) {
+ root.isLoaded = true;
+ } else {
+ console.error("PrivacyIndicator: Failed to load history file:", error);
+ root.isLoaded = true; // Try to continue anyway
+ }
+ }
+ }
+
+ function saveHistory() {
+ if (!stateFile || !isLoaded) return;
+
+ adapter.history = root.accessHistory;
+
+ // Ensure cache directory exists and save
+ try {
+ Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
+ Qt.callLater(() => {
+ try {
+ historyFileView.writeAdapter();
+ } catch (e) {
+ console.error("PrivacyIndicator: Failed to save history", e);
+ }
+ });
+ } catch (e) {
+ console.error("PrivacyIndicator: Failed to save history", e);
+ }
+ }
+
+ function addToHistory(app, type, icon, colorKey, action) {
+ var time = new Date().toLocaleTimeString(Qt.locale(), Locale.ShortFormat);
+ var entry = {
+ "appName": app,
+ "type": type,
+ "icon": icon,
+ "colorKey": colorKey,
+ "time": time,
+ "timestamp": Date.now(),
+ "action": action // "started" or "stopped"
+ };
+ var newHistory = [entry].concat(accessHistory);
+ if (newHistory.length > 50) newHistory = newHistory.slice(0, 50); // Increased limit as we have more entries now
+ accessHistory = newHistory;
+ saveHistory();
+ }
+
+ function clearHistory() {
+ accessHistory = [];
+ saveHistory();
+ }
+
+ function checkAppChanges(newApps, oldApps, type, icon, colorKey) {
+ if (!newApps && !oldApps) return;
+
+ // Check for new apps (Started)
+ if (newApps) {
+ for (var i = 0; i < newApps.length; i++) {
+ var app = newApps[i];
+ if (!oldApps || oldApps.indexOf(app) === -1) {
+ addToHistory(app, type, icon, colorKey, "started");
+ }
+ }
+ }
+
+ // Check for removed apps (Stopped)
+ if (oldApps) {
+ for (var j = 0; j < oldApps.length; j++) {
+ var oldApp = oldApps[j];
+ if (!newApps || newApps.indexOf(oldApp) === -1) {
+ addToHistory(oldApp, type, icon, colorKey, "stopped");
+ }
+ }
+ }
+ }
+
+
+ onMicAppsChanged: {
+ checkAppChanges(micApps, _prevMicApps, "Microphone", "microphone", activeColorKey);
+ _prevMicApps = micApps;
+ }
+ // Helper to detect activation edge
+ property bool oldMicActive: false
+ onMicActiveChanged: {
+ if (enableToast && micActive && !oldMicActive) {
+ ToastService.showNotice(pluginApi?.tr("toast.mic-on"), "", "microphone");
+ }
+ oldMicActive = micActive
+ }
+
+ property bool oldCamActive: false
+ onCamActiveChanged: {
+ if (enableToast && camActive && !oldCamActive) {
+ ToastService.showNotice(pluginApi?.tr("toast.cam-on"), "", "camera");
+ }
+ oldCamActive = camActive
+ }
+ onCamAppsChanged: {
+ checkAppChanges(camApps, _prevCamApps, "Camera", "camera", activeColorKey);
+ _prevCamApps = camApps;
+ }
+
+ property bool oldScrActive: false
+ onScrActiveChanged: {
+ if (enableToast && scrActive && !oldScrActive) {
+ ToastService.showNotice(pluginApi?.tr("toast.screen-on"), "", "screen-share");
+ }
+ oldScrActive = scrActive
+ }
+ onScrAppsChanged: {
+ checkAppChanges(scrApps, _prevScrApps, "Screen", "screen-share", activeColorKey);
+ _prevScrApps = scrApps;
+ }
+
+
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/Panel.qml b/.config/noctalia/plugins/privacy-indicator/Panel.qml
new file mode 100644
index 0000000..0cfcc22
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/Panel.qml
@@ -0,0 +1,170 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ // Standard panel properties
+ readonly property var geometryPlaceholder: panelContainer
+ property real contentPreferredWidth: 320 * Style.uiScaleRatio
+ property real contentPreferredHeight: 450 * Style.uiScaleRatio
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property bool allowAttach: true
+
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ // Header Box
+ NBox {
+ Layout.fillWidth: true
+ Layout.preferredHeight: headerRow.implicitHeight + Style.marginM * 2
+
+ RowLayout {
+ id: headerRow
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "shield-check"
+ color: Color.mPrimary
+ pointSize: Style.fontSizeL
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: pluginApi?.tr("history.title")
+ font.weight: Style.fontWeightBold
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurface
+ }
+
+ NIconButton {
+ icon: "trash"
+ baseSize: Style.baseWidgetSize * 0.8
+ onClicked: {
+ if (mainInstance) mainInstance.clearHistory();
+ }
+ }
+ }
+ }
+
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ NScrollView {
+ id: scrollView
+ anchors.fill: parent
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+
+ ColumnLayout {
+ width: scrollView.availableWidth
+ spacing: Style.marginS
+
+ Repeater {
+ model: mainInstance ? mainInstance.accessHistory : []
+
+ delegate: Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 56 * Style.uiScaleRatio
+ radius: Style.radiusM
+ color: Color.mSurfaceVariant
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ Rectangle {
+ width: 32 * Style.uiScaleRatio
+ height: 32 * Style.uiScaleRatio
+ radius: width/2
+ color: Qt.alpha(iconColor, 0.1)
+
+ readonly property color iconColor: Color.resolveColorKey(modelData.colorKey || "primary")
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: modelData.icon
+ color: parent.iconColor
+ pointSize: Style.fontSizeM
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ NText {
+ Layout.fillWidth: true
+ text: modelData.appName
+ elide: Text.ElideRight
+ font.weight: Style.fontWeightBold
+ pointSize: Style.fontSizeM
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NText {
+ text: modelData.time
+ color: Qt.alpha(Color.mOnSurface, 0.7)
+ pointSize: Style.fontSizeS
+ }
+
+ NText {
+ text: "•"
+ color: Qt.alpha(Color.mOnSurface, 0.3)
+ pointSize: Style.fontSizeS
+ }
+
+ NText {
+ text: {
+ const action = modelData.action || "started";
+ return pluginApi?.tr("history.action." + action) || action;
+ }
+ color: (modelData.action || "started") === "stopped" ? Color.resolveColorKey("error") : Color.resolveColorKey("primary")
+ font.weight: Style.fontWeightBold
+ pointSize: Style.fontSizeS
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Empty state
+ NText {
+ Layout.alignment: Qt.AlignHCenter
+ visible: (!mainInstance || mainInstance.accessHistory.length === 0)
+ text: pluginApi?.tr("history.empty")
+ color: Qt.alpha(Color.mOnSurface, 0.5)
+ pointSize: Style.fontSizeM
+ Layout.topMargin: Style.marginL
+ }
+
+ Item { Layout.fillHeight: true } // spacer
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/README.md b/.config/noctalia/plugins/privacy-indicator/README.md
new file mode 100644
index 0000000..d192040
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/README.md
@@ -0,0 +1,47 @@
+# Privacy Indicator Plugin
+
+A privacy indicator widget that monitors and displays when microphone, camera, or screen sharing is active on your system.
+
+## Features
+
+- **Microphone Monitoring**: Detects active microphone usage via Pipewire
+- **Camera Monitoring**: Detects active camera usage by checking `/dev/video*` devices
+- **Screen Sharing Detection**: Monitors screen sharing sessions via Pipewire
+- **Visual Indicators**: Shows icons that change color based on active state
+ - Active: Primary color
+ - Inactive: Semi-transparent variant color
+- **App Information**: Tooltip displays which applications are using each resource
+- **Adaptive Layout**: Automatically adjusts layout for horizontal or vertical bar positions
+
+## Configuration
+
+Access the plugin settings in Noctalia to configure the following options:
+
+- **Hide Inactive States**: If enabled, microphone, camera, and screen icons are hidden whenever they are inactive. Only active states are shown.
+- **Remove Margins**: If enabled, removes all outer margins of the widget.
+- **Icon Spacing**: Controls the horizontal/vertical spacing between the icons.
+- **Active/Inactive Icon Color**: Customize the colors for active and inactive states.
+- **Microphone Filter Regex**: Regex pattern to filter out specific microphone applications. Matching apps are completely excluded from detection (they won't trigger the indicator or appear in tooltips). Use `|` to specify multiple patterns, e.g., `effect_input.rnnoise|easyeffects`.
+
+
+## Usage
+
+The widget displays three icons in the bar:
+- **Microphone**: Shows when any app is using the microphone
+- **Camera**: Shows when any app is accessing the camera
+- **Screen Share**: Shows when screen sharing is active
+
+Hover over the widget to see a tooltip listing which applications are using each resource.
+
+## Requirements
+
+- Noctalia Shell 3.6.0 or higher
+- Pipewire (for microphone and screen sharing detection)
+- Access to `/dev/video*` devices (for camera detection)
+
+## Technical Details
+
+- Updates privacy state every second
+- Uses Pipewire API to monitor audio/video streams
+- Checks `/proc/[0-9]*/fd/` for camera device access
+- Detects screen sharing by analyzing Pipewire node properties and media class
diff --git a/.config/noctalia/plugins/privacy-indicator/Settings.qml b/.config/noctalia/plugins/privacy-indicator/Settings.qml
new file mode 100644
index 0000000..a6acde9
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/Settings.qml
@@ -0,0 +1,142 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+
+ property var pluginApi: null
+
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ property bool hideInactive: cfg.hideInactive ?? defaults.hideInactive ?? false
+ property bool enableToast: cfg.enableToast ?? defaults.enableToast ?? true
+ property bool removeMargins: cfg.removeMargins ?? defaults.removeMargins ?? false
+ property int iconSpacing: cfg.iconSpacing ?? defaults.iconSpacing ?? 4
+ property string activeColor: cfg.activeColor ?? defaults.activeColor ?? "primary"
+ property string inactiveColor: cfg.inactiveColor ?? defaults.inactiveColor ?? "none"
+ property string micFilterRegex: cfg.micFilterRegex ?? defaults.micFilterRegex
+ property string camFilterRegex: cfg.camFilterRegex ?? defaults.camFilterRegex
+
+ spacing: Style.marginL
+
+ Component.onCompleted: {
+ Logger.i("PrivacyIndicator", "Settings UI loaded");
+ }
+
+ ColumnLayout {
+ spacing: Style.marginM
+ Layout.fillWidth: true
+
+ NToggle {
+ label: pluginApi?.tr("settings.hideInactive.label")
+ description: pluginApi?.tr("settings.hideInactive.desc")
+
+ checked: root.hideInactive
+ onToggled: checked => {
+ root.hideInactive = checked;
+ }
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.enableToast.label")
+ description: pluginApi?.tr("settings.enableToast.desc")
+
+ checked: root.enableToast
+ onToggled: checked => {
+ root.enableToast = checked;
+ }
+ }
+
+ NToggle {
+ label: pluginApi?.tr("settings.removeMargins.label")
+ description: pluginApi?.tr("settings.removeMargins.desc")
+
+ checked: root.removeMargins
+ onToggled: checked => {
+ root.removeMargins = checked;
+ }
+ }
+
+ NColorChoice {
+ label: pluginApi?.tr("settings.activeColor.label")
+ description: pluginApi?.tr("settings.activeColor.desc")
+ currentKey: root.activeColor
+ onSelected: key => root.activeColor = key
+ }
+
+ NColorChoice {
+ label: pluginApi?.tr("settings.inactiveColor.label")
+ description: pluginApi?.tr("settings.inactiveColor.desc")
+ currentKey: root.inactiveColor
+ onSelected: key => root.inactiveColor = key
+ noneColor: Qt.alpha(Color.mOnSurfaceVariant, 0.3)
+ noneOnColor: Qt.alpha(Color.mOnSurface, 0.7)
+ }
+
+ NComboBox {
+ label: pluginApi?.tr("settings.iconSpacing.label")
+ description: pluginApi?.tr("settings.iconSpacing.desc")
+
+ model: {
+ const labels = ["XXS", "XS", "S", "M", "L", "XL"];
+ const values = [Style.marginXXS, Style.marginXS, Style.marginS, Style.marginM, Style.marginL, Style.marginXL];
+
+ const result = [];
+ for (var i = 0; i < labels.length; ++i) {
+ const v = values[i];
+ result.push({
+ key: v.toFixed(0),
+ name: `${labels[i]} (${v}px)`
+ });
+ }
+ return result;
+ }
+
+ // INFO: From my understanding, the toFixed(0) shouldn't be needed here and there, but without the
+ // current key does not show when opening the settings window.
+ currentKey: root.iconSpacing.toFixed(0)
+ onSelected: key => root.iconSpacing = key
+ }
+
+ NTextInput {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.micFilterRegex.label")
+ description: pluginApi?.tr("settings.micFilterRegex.desc")
+ placeholderText: "effect_input.rnnoise|easyeffects"
+ text: root.micFilterRegex
+ onTextChanged: root.micFilterRegex = text
+ }
+
+ NTextInput {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.camFilterRegex.label")
+ description: pluginApi?.tr("settings.camFilterRegex.desc")
+ placeholderText: "droidcam"
+ text: root.camFilterRegex
+ onTextChanged: root.camFilterRegex = text
+ }
+ }
+
+ function saveSettings() {
+ if (!pluginApi) {
+ Logger.e("PrivacyIndicator", "Cannot save settings: pluginApi is null");
+ return;
+ }
+
+ pluginApi.pluginSettings.hideInactive = root.hideInactive;
+ pluginApi.pluginSettings.enableToast = root.enableToast;
+ pluginApi.pluginSettings.iconSpacing = root.iconSpacing;
+ pluginApi.pluginSettings.removeMargins = root.removeMargins;
+ pluginApi.pluginSettings.activeColor = root.activeColor;
+ pluginApi.pluginSettings.inactiveColor = root.inactiveColor;
+ pluginApi.pluginSettings.micFilterRegex = root.micFilterRegex;
+ pluginApi.pluginSettings.camFilterRegex = root.camFilterRegex;
+
+ pluginApi.saveSettings();
+
+ Logger.i("PrivacyIndicator", "Settings saved successfully");
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/de.json b/.config/noctalia/plugins/privacy-indicator/i18n/de.json
new file mode 100644
index 0000000..5817042
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/de.json
@@ -0,0 +1,58 @@
+{
+ "menu": {
+ "settings": "Widget Einstellungen"
+ },
+ "settings": {
+ "activeColor": {
+ "desc": "Farbe der Symbole, wenn sie aktiv sind.",
+ "label": "Aktive Farbsymbol"
+ },
+ "hideInactive": {
+ "desc": "Mikrofon-, Kamera- und Bildschirmsymbole ausblenden, wenn sie inaktiv sind.",
+ "label": "Inaktive Zustände ausblenden"
+ },
+ "enableToast": {
+ "desc": "Zeige Toast Mitteilungen an, wenn sich einer der Zustände ändert.",
+ "label": "Aktiviere Toast Mitteilungen"
+ },
+ "inactiveColor": {
+ "desc": "Farbe der Symbole, wenn sie inaktiv sind.",
+ "label": "Inaktive Farbsymbol"
+ },
+ "iconSpacing": {
+ "desc": "Den Abstand zwischen den Symbolen festlegen.",
+ "label": "Symbolabstand"
+ },
+ "removeMargins": {
+ "desc": "Alle äußeren Ränder des Widgets entfernen.",
+ "label": "Ränder entfernen"
+ },
+ "micFilterRegex": {
+ "desc": "Regex Muster zum Herausfiltern von Mikrofon-Apps. Entsprechende Apps werden vollständig von der Erkennung ausgeschlossen.",
+ "label": "Regex für Mikrofonfilter"
+ },
+ "camFilterRegex": {
+ "desc": "Regex-Muster zum Herausfiltern von Kamera-Apps. Entsprechende Apps werden vollständig von der Erkennung ausgeschlossen.",
+ "label": "Regex für Kamerafilter"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mikrofon: {apps}",
+ "screen-on": "Bildschirmfreigabe: {apps}"
+ },
+ "toast": {
+ "cam-on": "Kamera ist aktiv",
+ "mic-on": "Mikrofon ist aktiv",
+ "screen-on": "Bildschirmfreigabe ist aktiv"
+ },
+ "history": {
+ "title": "Zugriffsverlauf",
+ "empty": "Kein kürzlicher Zugriff",
+ "clear": "Leeren",
+ "action": {
+ "started": "Gestartet",
+ "stopped": "Beendet"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/en.json b/.config/noctalia/plugins/privacy-indicator/i18n/en.json
new file mode 100644
index 0000000..fa65418
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/en.json
@@ -0,0 +1,58 @@
+{
+ "menu": {
+ "settings": "Widget settings"
+ },
+ "settings": {
+ "activeColor": {
+ "desc": "Color of the icons when active.",
+ "label": "Active icon color"
+ },
+ "hideInactive": {
+ "desc": "Hide microphone, camera, and screen icons when there are inactive.",
+ "label": "Hide inactive states"
+ },
+ "enableToast": {
+ "desc": "Show toast messages when one of the states changes.",
+ "label": "Enable toast notifications"
+ },
+ "inactiveColor": {
+ "desc": "Color of the icons when inactive.",
+ "label": "Inactive icon color"
+ },
+ "iconSpacing": {
+ "desc": "Set the spacing between the icons.",
+ "label": "Icon spacing"
+ },
+ "removeMargins": {
+ "desc": "Remove all outer margins of the widget.",
+ "label": "Remove margins"
+ },
+ "micFilterRegex": {
+ "desc": "Regex pattern to filter out microphone applications. Matching apps are completely excluded from detection.",
+ "label": "Microphone filter regex"
+ },
+ "camFilterRegex": {
+ "desc": "Regex pattern to filter out camera applications. Matching apps are completely excluded from detection.",
+ "label": "Camera filter regex"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Camera: {apps}",
+ "mic-on": "Microphone: {apps}",
+ "screen-on": "Screen sharing: {apps}"
+ },
+ "toast": {
+ "cam-on": "Camera is active",
+ "mic-on": "Microphone is active",
+ "screen-on": "Screen sharing is active"
+ },
+ "history": {
+ "title": "Access History",
+ "empty": "No recent access",
+ "clear": "Clear",
+ "action": {
+ "started": "Started",
+ "stopped": "Stopped"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/es.json b/.config/noctalia/plugins/privacy-indicator/i18n/es.json
new file mode 100644
index 0000000..93a48a7
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/es.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Ocultar los iconos de micrófono, cámara y pantalla cuando estén inactivos.",
+ "label": "Ocultar estados inactivos"
+ },
+ "iconSpacing": {
+ "desc": "Configurar el espacio entre los iconos.",
+ "label": "Espaciado de iconos"
+ },
+ "removeMargins": {
+ "desc": "Quita todos los márgenes externos del widget.",
+ "label": "Quitar márgenes"
+ },
+ "activeColor": {
+ "desc": "Color de los iconos cuando están activos.",
+ "label": "Color de icono activo"
+ },
+ "inactiveColor": {
+ "desc": "Color de los iconos cuando están inactivos.",
+ "label": "Color de icono inactivo"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Cámara: {apps}",
+ "mic-on": "Micrófono: {apps}",
+ "screen-on": "Compartir pantalla: {apps}"
+ },
+ "toast": {
+ "cam-on": "La cámara está activa",
+ "mic-on": "El micrófono está activo",
+ "screen-on": "Compartir pantalla está activo"
+ },
+ "menu": {
+ "settings": "Configuración del widget"
+ },
+ "history": {
+ "title": "Historial de acceso",
+ "empty": "Sin accesos recientes",
+ "clear": "Borrar",
+ "action": {
+ "started": "Iniciado",
+ "stopped": "Detenido"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/fr.json b/.config/noctalia/plugins/privacy-indicator/i18n/fr.json
new file mode 100644
index 0000000..814c07d
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/fr.json
@@ -0,0 +1,58 @@
+{
+ "menu": {
+ "settings": "Paramètres du widget"
+ },
+ "settings": {
+ "activeColor": {
+ "desc": "Couleur des icônes lorsqu'elles sont actives.",
+ "label": "Couleur d'icône active"
+ },
+ "hideInactive": {
+ "desc": "Masquer les icônes du micro, de la caméra et de l’écran lorsqu’ils sont inactifs.",
+ "label": "Masquer les états inactifs"
+ },
+ "enableToast": {
+ "desc": "Afficher des messages toast lorsque l'un des états change.",
+ "label": "Activer les notifications toast"
+ },
+ "inactiveColor": {
+ "desc": "Couleur des icônes lorsqu'elles sont inactives.",
+ "label": "Couleur d'icône inactive"
+ },
+ "iconSpacing": {
+ "desc": "Définir l’espacement entre les icônes.",
+ "label": "Espacement des icônes"
+ },
+ "removeMargins": {
+ "desc": "Supprime toutes les marges extérieures du widget.",
+ "label": "Supprimer les marges"
+ },
+ "micFilterRegex": {
+ "desc": "Motif Regex pour filtrer les applications de microphone. Les applications correspondantes sont complètement exclues de la détection.",
+ "label": "Regex de filtrage du microphone"
+ },
+ "camFilterRegex": {
+ "desc": "Motif Regex pour filtrer les applications de caméra. Les applications correspondantes sont complètement exclues de la détection.",
+ "label": "Regex de filtrage de la caméra"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Caméra: {apps}",
+ "mic-on": "Microphone: {apps}",
+ "screen-on": "Partage d'écran: {apps}"
+ },
+ "toast": {
+ "cam-on": "La caméra est active",
+ "mic-on": "Le microphone est actif",
+ "screen-on": "Le partage d'écran est actif"
+ },
+ "history": {
+ "title": "Historique d'accès",
+ "empty": "Aucun accès récent",
+ "clear": "Effacer",
+ "action": {
+ "started": "Démarré",
+ "stopped": "Arrêté"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/hu.json b/.config/noctalia/plugins/privacy-indicator/i18n/hu.json
new file mode 100644
index 0000000..4a7ba8c
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/hu.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Mikrofon, kamera és képernyő ikonok elrejtése, ha éppen nincsenek használatban.",
+ "label": "InaktÃv állapotok elrejtése"
+ },
+ "iconSpacing": {
+ "desc": "ÃllÃtsa be az ikonok közötti távolságot.",
+ "label": "Ikon távolság"
+ },
+ "removeMargins": {
+ "desc": "TávolÃtsd el a widget összes külsÅ‘ margóját.",
+ "label": "Margók eltávolÃtása"
+ },
+ "activeColor": {
+ "desc": "Az ikonok szÃne, amikor aktÃvak.",
+ "label": "AktÃv ikon szÃne"
+ },
+ "inactiveColor": {
+ "desc": "Az ikonok szÃne, amikor inaktÃvak.",
+ "label": "InaktÃv ikon szÃne"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mikrofon: {apps}",
+ "screen-on": "Képernyőmegosztás: {apps}"
+ },
+ "toast": {
+ "cam-on": "A kamera aktÃv",
+ "mic-on": "A mikrofon aktÃv",
+ "screen-on": "KépernyÅ‘megosztás aktÃv"
+ },
+ "menu": {
+ "settings": "Widget beállÃtások"
+ },
+ "history": {
+ "title": "Hozzáférési előzmények",
+ "empty": "Nincs nemrégiben történt hozzáférés",
+ "clear": "Törlés",
+ "action": {
+ "started": "ElindÃtva",
+ "stopped": "LeállÃtva"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/it.json b/.config/noctalia/plugins/privacy-indicator/i18n/it.json
new file mode 100644
index 0000000..d0eee79
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/it.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Nascondi le icone di microfono, fotocamera e schermo quando sono inattive.",
+ "label": "Nascondi stati inattivi"
+ },
+ "iconSpacing": {
+ "desc": "Imposta la distanza tra le icone.",
+ "label": "Spaziatura delle icone"
+ },
+ "removeMargins": {
+ "desc": "Rimuove tutti i margini esterni del widget.",
+ "label": "Rimuovi margini"
+ },
+ "activeColor": {
+ "desc": "Colore delle icone quando sono attive.",
+ "label": "Colore icona attiva"
+ },
+ "inactiveColor": {
+ "desc": "Colore delle icone quando sono inattive.",
+ "label": "Colore icona inattiva"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mikrofonoa: {apps}",
+ "screen-on": "Ekran-partaÄado: {apps}"
+ },
+ "toast": {
+ "cam-on": "La fotocamera è attiva",
+ "mic-on": "Il microfono è attivo",
+ "screen-on": "La condivisione dello schermo è attiva"
+ },
+ "menu": {
+ "settings": "Impostazioni widget"
+ },
+ "history": {
+ "title": "Cronologia accessi",
+ "empty": "Nessun accesso recente",
+ "clear": "Pulisci",
+ "action": {
+ "started": "Iniziato",
+ "stopped": "Terminato"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/ja.json b/.config/noctalia/plugins/privacy-indicator/i18n/ja.json
new file mode 100644
index 0000000..ba61aeb
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/ja.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "マイク・カメラ・画é¢å…±æœ‰ã®ã‚¢ã‚¤ã‚³ãƒ³ã‚’ã€éžã‚¢ã‚¯ãƒ†ã‚£ãƒ–ãªã¨ãã¯éžè¡¨ç¤ºã«ã—ã¾ã™ã€‚",
+ "label": "éžã‚¢ã‚¯ãƒ†ã‚£ãƒ–状態をéžè¡¨ç¤º"
+ },
+ "iconSpacing": {
+ "desc": "アイコンåŒå£«ã®é–“隔をè¨å®šã—ã¾ã™ã€‚",
+ "label": "アイコン間隔"
+ },
+ "removeMargins": {
+ "desc": "ウィジェットã®å¤–å´ã®ä½™ç™½ã‚’ã™ã¹ã¦å‰Šé™¤ã—ã¾ã™ã€‚",
+ "label": "余白を削除"
+ },
+ "activeColor": {
+ "desc": "アクティブ時ã®ã‚¢ã‚¤ã‚³ãƒ³ã®è‰²ã€‚",
+ "label": "アクティブ時ã®è‰²"
+ },
+ "inactiveColor": {
+ "desc": "éžã‚¢ã‚¯ãƒ†ã‚£ãƒ–時ã®ã‚¢ã‚¤ã‚³ãƒ³ã®è‰²ã€‚",
+ "label": "éžã‚¢ã‚¯ãƒ†ã‚£ãƒ–時ã®è‰²"
+ }
+ },
+ "tooltip": {
+ "cam-on": "カメラ: {apps}",
+ "mic-on": "マイク: {apps}",
+ "screen-on": "ç”»é¢å…±æœ‰: {apps}"
+ },
+ "toast": {
+ "cam-on": "カメラãŒã‚¢ã‚¯ãƒ†ã‚£ãƒ–ã§ã™",
+ "mic-on": "マイクãŒã‚¢ã‚¯ãƒ†ã‚£ãƒ–ã§ã™",
+ "screen-on": "ç”»é¢å…±æœ‰ãŒã‚¢ã‚¯ãƒ†ã‚£ãƒ–ã§ã™"
+ },
+ "menu": {
+ "settings": "ウィジェットè¨å®š"
+ },
+ "history": {
+ "title": "アクセス履æ´",
+ "empty": "最近ã®ã‚¢ã‚¯ã‚»ã‚¹ã¯ã‚りã¾ã›ã‚“",
+ "clear": "クリア",
+ "action": {
+ "started": "é–‹å§‹",
+ "stopped": "åœæ¢"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/ko.json b/.config/noctalia/plugins/privacy-indicator/i18n/ko.json
new file mode 100644
index 0000000..e104202
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/ko.json
@@ -0,0 +1,58 @@
+{
+ "menu": {
+ "settings": "ìœ„ì ¯ ì„¤ì •"
+ },
+ "settings": {
+ "activeColor": {
+ "desc": "활성 ìƒíƒœì¼ 때 ì•„ì´ì½˜ì— ì ìš©ë˜ëŠ” 색ìƒìž…니다.",
+ "label": "활성 ì•„ì´ì½˜ 색ìƒ"
+ },
+ "hideInactive": {
+ "desc": "ë¹„í™œì„±í™”ëœ ìƒíƒœì˜ 마ì´í¬, ì¹´ë©”ë¼, 화면 ì•„ì´ì½˜ì„ 숨ê¹ë‹ˆë‹¤.",
+ "label": "비활성 ìƒíƒœ 숨기기"
+ },
+ "enableToast": {
+ "desc": "ìƒíƒœê°€ 변경ë 때 í† ìŠ¤íŠ¸ ì•Œë¦¼ì„ í‘œì‹œí•©ë‹ˆë‹¤.",
+ "label": "í† ìŠ¤íŠ¸ 알림 활성화"
+ },
+ "inactiveColor": {
+ "desc": "비활성 ìƒíƒœì¼ 때 ì•„ì´ì½˜ì— ì ìš©ë˜ëŠ” 색ìƒìž…니다.",
+ "label": "비활성 ì•„ì´ì½˜ 색ìƒ"
+ },
+ "iconSpacing": {
+ "desc": "ì•„ì´ì½˜ 사ì´ì˜ ê°„ê²©ì„ ì„¤ì •í•©ë‹ˆë‹¤.",
+ "label": "ì•„ì´ì½˜ 간격"
+ },
+ "removeMargins": {
+ "desc": "ìœ„ì ¯ì˜ ëª¨ë“ ì™¸ê³½ ì—¬ë°±ì„ ì œê±°í•©ë‹ˆë‹¤.",
+ "label": "여백 ì œê±°"
+ },
+ "micFilterRegex": {
+ "desc": "마ì´í¬ë¥¼ 사용하는 ì• í”Œë¦¬ì¼€ì´ì…˜ì„ í•„í„°ë§í•˜ê¸° 위한 ì •ê·œì‹ì„ ì§€ì •í•©ë‹ˆë‹¤. ì¼ì¹˜í•˜ëŠ” ì• í”Œë¦¬ì¼€ì´ì…˜ì€ ìƒíƒœ ê°ì§€ì—서 ì™„ì „ížˆ ì œì™¸ë©ë‹ˆë‹¤.",
+ "label": "마ì´í¬ í•„í„° ì •ê·œì‹"
+ },
+ "camFilterRegex": {
+ "desc": "ì¹´ë©”ë¼ë¥¼ 사용하는 ì• í”Œë¦¬ì¼€ì´ì…˜ì„ í•„í„°ë§í•˜ê¸° 위한 ì •ê·œì‹ì„ ì§€ì •í•©ë‹ˆë‹¤. ì¼ì¹˜í•˜ëŠ” ì• í”Œë¦¬ì¼€ì´ì…˜ì€ ìƒíƒœ ê°ì§€ì—서 ì™„ì „ížˆ ì œì™¸ë©ë‹ˆë‹¤.",
+ "label": "ì¹´ë©”ë¼ í•„í„° ì •ê·œì‹"
+ }
+ },
+ "tooltip": {
+ "cam-on": "ì¹´ë©”ë¼: {apps}",
+ "mic-on": "마ì´í¬: {apps}",
+ "screen-on": "화면 ê³µìœ : {apps}"
+ },
+ "toast": {
+ "cam-on": "ì¹´ë©”ë¼ê°€ 활성화ë˜ì—ˆìŠµë‹ˆë‹¤.",
+ "mic-on": "마ì´í¬ê°€ 활성화ë˜ì—ˆìŠµë‹ˆë‹¤.",
+ "screen-on": "화면 ê³µìœ ê°€ 활성화ë˜ì—ˆìŠµë‹ˆë‹¤."
+ },
+ "history": {
+ "title": "ì ‘ê·¼ 기ë¡",
+ "empty": "ìµœê·¼ì— ì ‘ê·¼í•œ ê¸°ë¡ ì—†ìŒ",
+ "clear": "비우기",
+ "action": {
+ "started": "시작ë¨",
+ "stopped": "ì •ì§€ë¨"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/ku.json b/.config/noctalia/plugins/privacy-indicator/i18n/ku.json
new file mode 100644
index 0000000..6a58f84
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/ku.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Îkonên mîkrofon, kamera û ekranê dema ku neçalak bin veşêre.",
+ "label": "Rewşa neçalak veşêre"
+ },
+ "iconSpacing": {
+ "desc": "Cihê di navbera îkonan de diyar bike.",
+ "label": "Dûrahiya îkonan"
+ },
+ "removeMargins": {
+ "desc": "Hemû marjînalên derveyî yên widgetê rake.",
+ "label": "Derdestên derxînin"
+ },
+ "activeColor": {
+ "desc": "Color of the icons when active.",
+ "label": "Active icon color"
+ },
+ "inactiveColor": {
+ "desc": "Color of the icons when inactive.",
+ "label": "Inactive icon color"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mîkrofon: {apps}",
+ "screen-on": "Parvekirina ekranê: {apps}"
+ },
+ "toast": {
+ "cam-on": "Kamera çalak e",
+ "mic-on": "Mîkrofon çalak e",
+ "screen-on": "Parvekirina ekranê çalak e"
+ },
+ "menu": {
+ "settings": "Widget settings"
+ },
+ "history": {
+ "title": "Access History",
+ "empty": "No recent access",
+ "clear": "Clear",
+ "action": {
+ "started": "Started",
+ "stopped": "Stopped"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/nl.json b/.config/noctalia/plugins/privacy-indicator/i18n/nl.json
new file mode 100644
index 0000000..d0b031d
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/nl.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Verberg de pictogrammen voor microfoon, camera en scherm wanneer ze inactief zijn.",
+ "label": "Inactieve status verbergen"
+ },
+ "iconSpacing": {
+ "desc": "Stel de afstand tussen de pictogrammen in.",
+ "label": "Pictogramafstand"
+ },
+ "removeMargins": {
+ "desc": "Verwijdert alle buitenste marges van de widget.",
+ "label": "Marges verwijderen"
+ },
+ "activeColor": {
+ "desc": "Kleur van de pictogrammen wanneer ze actief zijn.",
+ "label": "Actieve pictogramkleur"
+ },
+ "inactiveColor": {
+ "desc": "Kleur van de pictogrammen wanneer ze inactief zijn.",
+ "label": "Inactieve pictogramkleur"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Camera: {apps}",
+ "mic-on": "Microfoon: {apps}",
+ "screen-on": "Schermdeling: {apps}"
+ },
+ "toast": {
+ "cam-on": "Camera is actief",
+ "mic-on": "Microfoon is actief",
+ "screen-on": "Scherm delen is actief"
+ },
+ "menu": {
+ "settings": "Widget instellingen"
+ },
+ "history": {
+ "title": "Toegangsgeschiedenis",
+ "empty": "Geen recente toegang",
+ "clear": "Wissen",
+ "action": {
+ "started": "Gestart",
+ "stopped": "Gestopt"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/pl.json b/.config/noctalia/plugins/privacy-indicator/i18n/pl.json
new file mode 100644
index 0000000..9e05d3a
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/pl.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Ukryj ikony mikrofonu, kamery i ekranu, gdy sÄ… nieaktywne.",
+ "label": "Ukryj nieaktywne stany"
+ },
+ "iconSpacing": {
+ "desc": "Ustaw odstęp między ikonami.",
+ "label": "Odstępy ikon"
+ },
+ "removeMargins": {
+ "desc": "Usuń wszystkie zewnętrzne marginesy widżetu.",
+ "label": "Usuń marginesy"
+ },
+ "activeColor": {
+ "desc": "Kolor ikon, gdy sÄ… aktywne.",
+ "label": "Kolor aktywnej ikony"
+ },
+ "inactiveColor": {
+ "desc": "Kolor ikon, gdy sÄ… nieaktywne.",
+ "label": "Kolor nieaktywnej ikony"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mikrofon: {apps}",
+ "screen-on": "Udostępnianie ekranu: {apps}"
+ },
+ "toast": {
+ "cam-on": "Kamera jest aktywna",
+ "mic-on": "Mikrofon jest aktywny",
+ "screen-on": "Udostępnianie ekranu jest aktywne"
+ },
+ "menu": {
+ "settings": "Ustawienia widżetu"
+ },
+ "history": {
+ "title": "Historia dostępu",
+ "empty": "Brak ostatnich dostępów",
+ "clear": "Wyczyść",
+ "action": {
+ "started": "Rozpoczęto",
+ "stopped": "Zatrzymano"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/pt.json b/.config/noctalia/plugins/privacy-indicator/i18n/pt.json
new file mode 100644
index 0000000..068a528
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/pt.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Oculta os Ãcones de microfone, câmera e tela quando estiverem inativos.",
+ "label": "Ocultar estados inativos"
+ },
+ "iconSpacing": {
+ "desc": "Define o espaçamento entre os Ãcones.",
+ "label": "Espaçamento dos Ãcones"
+ },
+ "removeMargins": {
+ "desc": "Remove todas as margens externas do widget.",
+ "label": "Remover margens"
+ },
+ "activeColor": {
+ "desc": "Cor dos Ãcones quando ativos.",
+ "label": "Cor do Ãcone ativo"
+ },
+ "inactiveColor": {
+ "desc": "Cor dos Ãcones quando inativos.",
+ "label": "Cor do Ãcone inativo"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Câmera: {apps}",
+ "mic-on": "Microfone: {apps}",
+ "screen-on": "Compartilhamento de tela: {apps}"
+ },
+ "toast": {
+ "cam-on": "A câmera está ativa",
+ "mic-on": "O microfone está ativo",
+ "screen-on": "O compartilhamento de tela está ativo"
+ },
+ "menu": {
+ "settings": "Configurações do widget"
+ },
+ "history": {
+ "title": "Histórico de acesso",
+ "empty": "Sem acessos recentes",
+ "clear": "Limpar",
+ "action": {
+ "started": "Iniciado",
+ "stopped": "Interrompido"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/ru.json b/.config/noctalia/plugins/privacy-indicator/i18n/ru.json
new file mode 100644
index 0000000..152e7d8
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/ru.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Скрывать значки микрофона, камеры и Ñкрана, когда они неактивны.",
+ "label": "Скрывать неактивные ÑоÑтоÑниÑ"
+ },
+ "iconSpacing": {
+ "desc": "Задать раÑÑтоÑние между значками.",
+ "label": "Интервал между значками"
+ },
+ "removeMargins": {
+ "desc": "УдалÑет вÑе внешние отÑтупы виджета.",
+ "label": "Убрать отÑтупы"
+ },
+ "activeColor": {
+ "desc": "Цвет иконок при активации.",
+ "label": "Цвет активной иконки"
+ },
+ "inactiveColor": {
+ "desc": "Цвет иконок в Ñпокойном ÑоÑтоÑнии.",
+ "label": "Цвет неактивной иконки"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Камера: {apps}",
+ "mic-on": "Микрофон: {apps}",
+ "screen-on": "ДемонÑÑ‚Ñ€Ð°Ñ†Ð¸Ñ Ñкрана: {apps}"
+ },
+ "toast": {
+ "cam-on": "Камера активна",
+ "mic-on": "Микрофон активен",
+ "screen-on": "ДемонÑÑ‚Ñ€Ð°Ñ†Ð¸Ñ Ñкрана активна"
+ },
+ "menu": {
+ "settings": "ÐаÑтройки виджета"
+ },
+ "history": {
+ "title": "ИÑÑ‚Ð¾Ñ€Ð¸Ñ Ð´Ð¾Ñтупа",
+ "empty": "Ðет недавних доÑтупов",
+ "clear": "ОчиÑтить",
+ "action": {
+ "started": "Ðачато",
+ "stopped": "ОÑтановлено"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/tr.json b/.config/noctalia/plugins/privacy-indicator/i18n/tr.json
new file mode 100644
index 0000000..016f1be
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/tr.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Mikrofon, kamera ve ekran simgelerini pasif olduklarında gizle.",
+ "label": "Pasif durumları gizle"
+ },
+ "iconSpacing": {
+ "desc": "Simgeler arasındaki boşluğu ayarla.",
+ "label": "Simge aralığı"
+ },
+ "removeMargins": {
+ "desc": "Widget’ın tüm dış kenar boşluklarını kaldırır.",
+ "label": "Kenarlıkları kaldır"
+ },
+ "activeColor": {
+ "desc": "Aktif olduÄŸunda simgelerin rengi.",
+ "label": "Aktif simge rengi"
+ },
+ "inactiveColor": {
+ "desc": "Devre dışı olduğunda simgelerin rengi.",
+ "label": "Pasif simge rengi"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Kamera: {apps}",
+ "mic-on": "Mikrofon: {apps}",
+ "screen-on": "Ekran paylaşımı: {apps}"
+ },
+ "toast": {
+ "cam-on": "Kamera aktif",
+ "mic-on": "Mikrofon aktif",
+ "screen-on": "Ekran paylaşımı aktif"
+ },
+ "menu": {
+ "settings": "Bileşen ayarları"
+ },
+ "history": {
+ "title": "Erişim Geçmişi",
+ "empty": "Yakın zamanda erişim yok",
+ "clear": "Temizle",
+ "action": {
+ "started": "Başlatıldı",
+ "stopped": "Durduruldu"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/uk-UA.json b/.config/noctalia/plugins/privacy-indicator/i18n/uk-UA.json
new file mode 100644
index 0000000..5b872a7
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/uk-UA.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "Приховувати значки мікрофона, камери та екрана, коли вони неактивні.",
+ "label": "Приховувати неактивні Ñтани"
+ },
+ "iconSpacing": {
+ "desc": "Ð’Ñтановити відÑтань між значками.",
+ "label": "Інтервал між значками"
+ },
+ "removeMargins": {
+ "desc": "ВидалÑÑ” вÑÑ– зовнішні відÑтупи віджета.",
+ "label": "Прибрати відÑтупи"
+ },
+ "activeColor": {
+ "desc": "Колір іконок при активації.",
+ "label": "Колір активної іконки"
+ },
+ "inactiveColor": {
+ "desc": "Колір іконок у Ñпокійному Ñтані.",
+ "label": "Колір неактивної іконки"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Камера: {apps}",
+ "mic-on": "Мікрофон: {apps}",
+ "screen-on": "ДемонÑÑ‚Ñ€Ð°Ñ†Ñ–Ñ ÐµÐºÑ€Ð°Ð½Ð°: {apps}"
+ },
+ "toast": {
+ "cam-on": "Камера активна",
+ "mic-on": "Мікрофон активний",
+ "screen-on": "ДемонÑÑ‚Ñ€Ð°Ñ†Ñ–Ñ ÐµÐºÑ€Ð°Ð½Ð° активна"
+ },
+ "menu": {
+ "settings": "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´Ð¶ÐµÑ‚Ð°"
+ },
+ "history": {
+ "title": "ІÑÑ‚Ð¾Ñ€Ñ–Ñ Ð´Ð¾Ñтупу",
+ "empty": "Ðемає недавніх доÑтупів",
+ "clear": "ОчиÑтити",
+ "action": {
+ "started": "Розпочато",
+ "stopped": "Зупинено"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/vi.json b/.config/noctalia/plugins/privacy-indicator/i18n/vi.json
new file mode 100644
index 0000000..6cd6d76
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/vi.json
@@ -0,0 +1,54 @@
+{
+ "menu": {
+ "settings": "Cà i đặt tiện Ãch"
+ },
+ "settings": {
+ "activeColor": {
+ "desc": "Mà u của biểu tượng khi đang hoạt động.",
+ "label": "Mà u biểu tượng khi hoạt động"
+ },
+ "hideInactive": {
+ "desc": "Ẩn biểu tượng micro, camera và mà n hình khi không hoạt động.",
+ "label": "Ẩn trạng thái không hoạt động"
+ },
+ "enableToast": {
+ "desc": "Hiển thị thông báo khi một trạng thái thay đổi.",
+ "label": "Báºt thông báo"
+ },
+ "inactiveColor": {
+ "desc": "Mà u của biểu tượng khi không hoạt động.",
+ "label": "Mà u biểu tượng khi không hoạt động"
+ },
+ "iconSpacing": {
+ "desc": "Thiết láºp khoảng cách giữa các biểu tượng.",
+ "label": "Khoảng cách biểu tượng"
+ },
+ "removeMargins": {
+ "desc": "Loại bỠtoà n bộ lỠngoà i của widget.",
+ "label": "Xóa lá»"
+ },
+ "micFilterRegex": {
+ "desc": "Biểu thức chÃnh quy để lá»c các ứng dụng sá» dụng micro. Các ứng dụng khá»›p sẽ bị loại khá»i việc phát hiện.",
+ "label": "Regex lá»c micro"
+ }
+ },
+ "tooltip": {
+ "cam-on": "Camera: {apps}",
+ "mic-on": "Micro: {apps}",
+ "screen-on": "Chia sẻ mà n hình: {apps}"
+ },
+ "toast": {
+ "cam-on": "Camera đang hoạt động",
+ "mic-on": "Micro đang hoạt động",
+ "screen-on": "Chia sẻ mà n hình đang hoạt động"
+ },
+ "history": {
+ "title": "Lịch sá» truy cáºp",
+ "empty": "Không có truy cáºp gần đây",
+ "clear": "Xóa",
+ "action": {
+ "started": "Bắt đầu",
+ "stopped": "Dừng"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/zh-CN.json b/.config/noctalia/plugins/privacy-indicator/i18n/zh-CN.json
new file mode 100644
index 0000000..5665d78
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/zh-CN.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "åœ¨éº¦å…‹é£Žã€æ‘„åƒå¤´å’Œå±å¹•å›¾æ ‡å¤„äºŽéžæ´»åŠ¨çŠ¶æ€æ—¶å°†å…¶éšè—。",
+ "label": "éšè—éžæ´»åŠ¨çŠ¶æ€"
+ },
+ "iconSpacing": {
+ "desc": "è®¾ç½®å›¾æ ‡ä¹‹é—´çš„é—´è·ã€‚",
+ "label": "å›¾æ ‡é—´è·"
+ },
+ "removeMargins": {
+ "desc": "移除å°éƒ¨ä»¶æ‰€æœ‰å¤–部边è·ã€‚",
+ "label": "移除边è·"
+ },
+ "activeColor": {
+ "desc": "æ¿€æ´»å›¾æ ‡çš„é¢œè‰²ã€‚",
+ "label": "æ¿€æ´»å›¾æ ‡é¢œè‰²"
+ },
+ "inactiveColor": {
+ "desc": "æœªæ¿€æ´»å›¾æ ‡çš„é¢œè‰²ã€‚",
+ "label": "æœªæ¿€æ´»å›¾æ ‡é¢œè‰²"
+ }
+ },
+ "tooltip": {
+ "cam-on": "æ‘„åƒå¤´: {apps}",
+ "mic-on": "麦克风: {apps}",
+ "screen-on": "å±å¹•共享: {apps}"
+ },
+ "toast": {
+ "cam-on": "æ‘„åƒå¤´å·²æ¿€æ´»",
+ "mic-on": "麦克风已激活",
+ "screen-on": "å±å¹•共享已激活"
+ },
+ "menu": {
+ "settings": "å°éƒ¨ä»¶è®¾ç½®"
+ },
+ "history": {
+ "title": "访问历å²",
+ "empty": "æ— æœ€è¿‘è®¿é—®",
+ "clear": "清除",
+ "action": {
+ "started": "已开始",
+ "stopped": "å·²åœæ¢"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/i18n/zh-TW.json b/.config/noctalia/plugins/privacy-indicator/i18n/zh-TW.json
new file mode 100644
index 0000000..febeadc
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/i18n/zh-TW.json
@@ -0,0 +1,46 @@
+{
+ "settings": {
+ "hideInactive": {
+ "desc": "當麥克風, æ”影機åŠèž¢å¹•分享沒有啟動時就直接隱è—",
+ "label": "éš±è—æœªå•Ÿå‹•的狀態"
+ },
+ "iconSpacing": {
+ "desc": "è¨å®šåœ–示之間的留空",
+ "label": "圖示間è·"
+ },
+ "removeMargins": {
+ "desc": "移除å°å·¥å…·å¤–é¢çš„æ‰€æœ‰é‚Šè·",
+ "label": "移除邊è·"
+ },
+ "activeColor": {
+ "desc": "圖示啟用時的é¡è‰²ã€‚",
+ "label": "啟用圖示é¡è‰²"
+ },
+ "inactiveColor": {
+ "desc": "圖示未啟用時的é¡è‰²ã€‚",
+ "label": "未啟用圖示é¡è‰²"
+ }
+ },
+ "tooltip": {
+ "cam-on": "æ”影機: {apps}",
+ "mic-on": "麥克風: {apps}",
+ "screen-on": "螢幕分享: {apps}"
+ },
+ "toast": {
+ "cam-on": "æ”影機已啟用",
+ "mic-on": "麥克風已啟用",
+ "screen-on": "螢幕分享已啟用"
+ },
+ "menu": {
+ "settings": "å°å·¥å…·è¨å®š"
+ },
+ "history": {
+ "title": "å˜å–紀錄",
+ "empty": "無最近å˜å–",
+ "clear": "清除",
+ "action": {
+ "started": "已開始",
+ "stopped": "å·²åœæ¢"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.config/noctalia/plugins/privacy-indicator/manifest.json b/.config/noctalia/plugins/privacy-indicator/manifest.json
new file mode 100644
index 0000000..4618fc4
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/manifest.json
@@ -0,0 +1,37 @@
+{
+ "id": "privacy-indicator",
+ "name": "Privacy Indicator",
+ "version": "1.2.13",
+ "minNoctaliaVersion": "3.6.0",
+ "author": "Noctalia Team",
+ "official": true,
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "A privacy indicator widget that shows when microphone, camera or screen sharing is active.",
+ "tags": [
+ "Bar",
+ "Privacy",
+ "Indicator"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "panel": "Panel.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {
+ "hideInactive": false,
+ "enableToast": true,
+ "removeMargins": false,
+ "iconSpacing": 4,
+ "activeColor": "primary",
+ "inactiveColor": "none",
+ "micFilterRegex": "",
+ "camFilterRegex": ""
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/privacy-indicator/settings.json b/.config/noctalia/plugins/privacy-indicator/settings.json
new file mode 100644
index 0000000..7c91bd0
--- /dev/null
+++ b/.config/noctalia/plugins/privacy-indicator/settings.json
@@ -0,0 +1,10 @@
+{
+ "hideInactive": true,
+ "enableToast": true,
+ "removeMargins": false,
+ "iconSpacing": 4,
+ "activeColor": "error",
+ "inactiveColor": "none",
+ "micFilterRegex": "",
+ "camFilterRegex": ""
+}
diff --git a/.config/noctalia/plugins/screen-recorder/BarWidget.qml b/.config/noctalia/plugins/screen-recorder/BarWidget.qml
new file mode 100644
index 0000000..6f39fd4
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/BarWidget.qml
@@ -0,0 +1,147 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Services.UI
+import qs.Services.System
+import qs.Widgets
+
+NIconButton {
+ id: root
+
+ property var pluginApi: null
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ // Bar positioning properties
+ readonly property string screenName: screen ? screen.name : ""
+ readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
+ readonly property bool isVertical: barPosition === "left" || barPosition === "right"
+ readonly property real barHeight: Style.getBarHeightForScreen(screenName)
+ readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
+ readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property bool hideInactive:
+ pluginApi?.pluginSettings?.hideInactive ??
+ pluginApi?.manifest?.metadata?.defaultSettings?.hideInactive ??
+ false
+
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+ readonly property string iconColorKey: cfg.iconColor ?? defaults.iconColor ?? "none"
+ readonly property color iconColor: Color.resolveColorKey(iconColorKey)
+
+ readonly property bool shouldShow: !hideInactive || (mainInstance?.isRecording ?? false) || (mainInstance?.isPending ?? false) || (mainInstance?.isReplaying ?? false)
+
+ visible: true
+ opacity: shouldShow ? 1.0 : 0.0
+ implicitWidth: shouldShow ? baseSize : 0
+ implicitHeight: shouldShow ? baseSize : 0
+
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationNormal }
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation { duration: Style.animationNormal }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation { duration: Style.animationNormal }
+ }
+
+ enabled: mainInstance?.isAvailable ?? false
+ icon: "camera-video"
+ tooltipText: mainInstance?.buildTooltip()
+ tooltipDirection: BarService.getTooltipDirection()
+ baseSize: root.capsuleHeight
+ applyUiScale: false
+ customRadius: Style.radiusL
+ colorBg: (mainInstance?.isRecording || mainInstance?.isPending) ? Color.mPrimary : (mainInstance?.isReplaying ? Qt.alpha(Color.mSecondary, 0.25) : Style.capsuleColor)
+ colorFg: (mainInstance?.isRecording || mainInstance?.isPending) ? Color.mOnPrimary : (mainInstance?.isReplaying ? Color.mSecondary : root.iconColor)
+ colorBorder: "transparent"
+ colorBorderHover: "transparent"
+ border.color: Style.capsuleBorderColor
+ border.width: Style.capsuleBorderWidth
+
+ onClicked: {
+ if (!enabled) {
+ ToastService.showError(pluginApi.tr("messages.not-installed"), pluginApi.tr("messages.not-installed-desc"))
+ return
+ }
+
+ if (mainInstance) {
+ mainInstance.toggleRecording()
+ if (!mainInstance.isRecording && !mainInstance.isPending) {
+ // Recording was stopped, close control center if open
+ var controlCenter = PanelService.getPanel("controlCenterPanel", screen)
+ if (controlCenter) {
+ controlCenter.close()
+ }
+ }
+ }
+ }
+
+ onRightClicked: {
+ PanelService.showContextMenu(contextMenu, root, screen);
+ }
+
+
+ NPopupContextMenu {
+ id: contextMenu
+
+ model: {
+ var items = [];
+
+ // Replay controls (only when replay is enabled)
+ if (mainInstance?.replayEnabled) {
+ if (mainInstance?.isReplaying) {
+ items.push({
+ "label": pluginApi.tr("messages.save-replay"),
+ "action": "save-replay",
+ "icon": "device-floppy"
+ });
+ items.push({
+ "label": pluginApi.tr("messages.stop-replay"),
+ "action": "stop-replay",
+ "icon": "stop"
+ });
+ } else {
+ items.push({
+ "label": pluginApi.tr("messages.start-replay"),
+ "action": "start-replay",
+ "icon": "player-play"
+ });
+ }
+ }
+
+ items.push({
+ "label": I18n.tr("actions.widget-settings"),
+ "action": "widget-settings",
+ "icon": "settings"
+ });
+
+ return items;
+ }
+
+ onTriggered: action => {
+ contextMenu.close();
+ PanelService.closeContextMenu(screen);
+
+ if (action === "widget-settings") {
+ BarService.openPluginSettings(screen, pluginApi.manifest);
+ } else if (action === "start-replay" && mainInstance) {
+ mainInstance.startReplay();
+ } else if (action === "stop-replay" && mainInstance) {
+ mainInstance.stopReplay();
+ } else if (action === "save-replay" && mainInstance) {
+ mainInstance.saveReplay();
+ }
+ }
+ }
+
+}
diff --git a/.config/noctalia/plugins/screen-recorder/ControlCenterWidget.qml b/.config/noctalia/plugins/screen-recorder/ControlCenterWidget.qml
new file mode 100644
index 0000000..55ceeca
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/ControlCenterWidget.qml
@@ -0,0 +1,22 @@
+
+import QtQuick
+import Quickshell
+import qs.Widgets
+import qs.Commons
+
+NIconButton {
+ property ShellScreen screen
+ property var pluginApi: null
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ enabled: mainInstance?.isAvailable ?? false
+ icon: "camera-video"
+ tooltipText: mainInstance?.buildTooltip()
+ colorFg: mainInstance?.isRecording ? Color.mOnPrimary : (mainInstance?.isReplaying ? Color.mSecondary : Color.mPrimary)
+ colorBg: mainInstance?.isRecording ? Color.mPrimary : (mainInstance?.isReplaying ? Qt.alpha(Color.mSecondary, 0.25) : Style.capsuleColor)
+ onClicked: {
+ if (pluginApi && pluginApi.mainInstance) {
+ pluginApi.mainInstance.toggleRecording();
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/Main.qml b/.config/noctalia/plugins/screen-recorder/Main.qml
new file mode 100644
index 0000000..8eb3d19
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/Main.qml
@@ -0,0 +1,756 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+import qs.Services.System
+import qs.Services.Compositor
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ property bool isRecording: false
+ property bool isPending: false
+ property bool hasActiveRecording: false
+ property string outputPath: ""
+ property bool isAvailable: false
+ property string detectedMonitor: ""
+ property bool usePrimeRun: false
+
+ // Replay state
+ property bool isReplaying: false
+ property bool isReplayPending: false
+
+ // Single reusable Process object
+ Process {
+ id: checker
+ running: true
+ command: ["sh", "-c", "command -v gpu-screen-recorder >/dev/null 2>&1 || (command -v flatpak >/dev/null 2>&1 && flatpak list --app | grep -q 'com.dec05eba.gpu_screen_recorder')"]
+
+ onExited: function (exitCode) {
+ isAvailable = (exitCode === 0);
+ running = false;
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ IpcHandler {
+ target: "plugin:screen-recorder"
+
+ function toggle() {
+ if (root.isAvailable) {
+ root.toggleRecording();
+ }
+ }
+
+ function start() {
+ if (root.isAvailable && !root.isRecording && !root.isPending) {
+ root.startRecording();
+ }
+ }
+
+ function stop() {
+ if (root.isRecording || root.isPending) {
+ root.stopRecording();
+ }
+ }
+
+ function toggleReplay() {
+ if (root.isAvailable) {
+ root.toggleReplay();
+ }
+ }
+
+ function startReplay() {
+ if (root.isAvailable && !root.isReplaying && !root.isReplayPending) {
+ root.startReplay();
+ }
+ }
+
+ function stopReplay() {
+ if (root.isReplaying || root.isReplayPending) {
+ root.stopReplay();
+ }
+ }
+
+ function saveReplay() {
+ if (root.isReplaying) {
+ root.saveReplay();
+ }
+ }
+ }
+
+ // Settings shortcuts
+ readonly property bool hideInactive: pluginApi?.pluginSettings?.hideInactive ?? false
+ readonly property string directory: pluginApi?.pluginSettings?.directory || ""
+ readonly property string filenamePattern: pluginApi?.pluginSettings?.filenamePattern || "recording_yyyyMMdd_HHmmss"
+ readonly property string frameRate: pluginApi?.pluginSettings?.frameRate || "60"
+ readonly property string customFrameRate: pluginApi?.pluginSettings?.customFrameRate || "60"
+ readonly property string audioCodec: pluginApi?.pluginSettings?.audioCodec || "opus"
+ readonly property string videoCodec: pluginApi?.pluginSettings?.videoCodec || "h264"
+ readonly property string quality: pluginApi?.pluginSettings?.quality || "very_high"
+ readonly property string colorRange: pluginApi?.pluginSettings?.colorRange || "limited"
+ readonly property bool showCursor: pluginApi?.pluginSettings?.showCursor ?? true
+ readonly property bool copyToClipboard: pluginApi?.pluginSettings?.copyToClipboard ?? false
+ readonly property string audioSource: pluginApi?.pluginSettings?.audioSource || "default_output"
+ readonly property string videoSource: pluginApi?.pluginSettings?.videoSource || "portal"
+ readonly property string resolution: pluginApi?.pluginSettings?.resolution || "original"
+ readonly property bool restorePortalSession: pluginApi?.pluginSettings?.restorePortalSession ?? false
+
+ // Replay settings shortcuts
+ readonly property bool replayEnabled: pluginApi?.pluginSettings?.replayEnabled ?? false
+ readonly property string replayDuration: pluginApi?.pluginSettings?.replayDuration || "30"
+ readonly property string customReplayDuration: pluginApi?.pluginSettings?.customReplayDuration || "30"
+ readonly property string replayStorage: pluginApi?.pluginSettings?.replayStorage || "ram"
+
+ readonly property var codecResolutionLimits: ({
+ "h264": "4096x4096"
+ })
+
+ function buildResolutionFlag() {
+ if (resolution !== "original") {
+ return `-s ${resolution}`;
+ }
+
+ var maxResolution = codecResolutionLimits[videoCodec];
+ return maxResolution ? `-s ${maxResolution}` : "";
+ }
+
+ function buildTooltip() {
+ if (!isAvailable) {
+ return pluginApi.tr("messages.not-installed");
+ }
+
+ if (isPending) {
+ pluginApi.tr("messages.started");
+ }
+
+ if (isRecording) {
+ return pluginApi.tr("messages.stop-recording");
+ }
+
+ if (isReplaying) {
+ return pluginApi.tr("messages.replay-active");
+ }
+
+ return pluginApi.tr("messages.start-recording");
+ }
+
+ // Start or Stop recording
+ function toggleRecording() {
+ (isRecording || isPending) ? stopRecording() : startRecording();
+ }
+
+ // Open recording file
+ function openFile(path) {
+ if (!path) {
+ return;
+ }
+ Quickshell.execDetached(["xdg-open", path]);
+ }
+
+ // Copy file to clipboard as file reference
+ function copyFileToClipboard(filePath) {
+ if (!filePath) {
+ return;
+ }
+ // Convert path to file:// URI format for copying as file reference
+ const fileUri = "file://" + filePath.replace(/ /g, "%20").replace(/'/g, "%27").replace(/"/g, "%22");
+ const escapedUri = fileUri.replace(/'/g, "'\\''");
+ const command = "printf '%s' '" + escapedUri + "' | wl-copy --type text/uri-list";
+ copyToClipboardProcess.exec({
+ "command": ["sh", "-c", command]
+ });
+ }
+
+ // Start screen recording
+ function startRecording() {
+ if (!isAvailable) {
+ return;
+ }
+ if (isRecording || isPending) {
+ return;
+ }
+ isPending = true;
+ hasActiveRecording = false;
+
+ // Close any opened panel
+ if ((PanelService.openedPanel !== null) && !PanelService.openedPanel.isClosing) {
+ PanelService.openedPanel.close();
+ }
+
+ // First, ensure xdg-desktop-portal and a compositor portal are running
+ portalCheckProcess.exec({
+ "command": ["sh", "-c", "pidof xdg-desktop-portal >/dev/null 2>&1 && (pidof xdg-desktop-portal-wlr >/dev/null 2>&1 || pidof xdg-desktop-portal-hyprland >/dev/null 2>&1 || pidof xdg-desktop-portal-gnome >/dev/null 2>&1 || pidof xdg-desktop-portal-kde >/dev/null 2>&1)"]
+ });
+ }
+
+ function expandFilenamePattern(pattern) {
+ var now = new Date();
+
+ // Known Qt date/time format tokens sorted by length (longest first to match greedily)
+ var tokens = ['unix', 'MMMM', 'dddd', 'yyyy', 'MMM', 'ddd', 'zzz', 'HH', 'hh', 'mm', 'ss', 'yy', 'MM', 'dd', 'AP', 'ap', 'M', 'd', 'H', 'h', 'm', 's', 'z', 'A', 'a', 't'];
+
+ // Escape literal text by wrapping non-token sequences in single quotes
+ var escaped = "";
+ var i = 0;
+ var literalBuffer = "";
+
+ while (i < pattern.length) {
+ var matched = false;
+
+ // Try to match each token at current position (longest first)
+ for (var j = 0; j < tokens.length; j++) {
+ var token = tokens[j];
+ if (pattern.substr(i, token.length) === token) {
+ // Check if this is a single-letter token that might be part of a word
+ // Only match single letters if they're preceded/followed by non-letter or another token
+ if (token.length === 1) {
+ var prevChar = i > 0 ? pattern[i - 1] : "";
+ var nextChar = i + 1 < pattern.length ? pattern[i + 1] : "";
+ // If surrounded by letters, it's probably part of a word, not a token
+ if ((prevChar.match(/[a-zA-Z]/) || nextChar.match(/[a-zA-Z]/))) {
+ continue; // Skip this token match, treat as literal
+ }
+ }
+
+ // Flush any accumulated literal text
+ if (literalBuffer) {
+ escaped += "'" + literalBuffer + "'";
+ literalBuffer = "";
+ }
+
+ if (token === 'unix') {
+ // Replace 'unix' token with the actual timestamp literal
+ // Do not wrap in quotes to avoid creating '' sequences which are interpreted as escaped quotes
+ escaped += Math.floor(now.getTime() / 1000);
+ } else {
+ // Add other tokens as-is
+ escaped += token;
+ }
+
+ i += token.length;
+ matched = true;
+ break;
+ }
+ }
+
+ if (!matched) {
+ // Character is not part of a token, add to literal buffer
+ literalBuffer += pattern[i];
+ i++;
+ }
+ }
+
+ // Flush any remaining literal text
+ if (literalBuffer) {
+ escaped += "'" + literalBuffer + "'";
+ }
+
+ // Use Qt's I18n.locale.toString for proper date/time formatting
+ var expanded = I18n.locale.toString(now, escaped);
+ return expanded + ".mp4";
+ }
+
+ function launchRecorder() {
+ // If focused-monitor is selected, detect monitor first
+ if (videoSource === "focused-monitor" && CompositorService.isHyprland) {
+ var script = 'set -euo pipefail\n' + 'pos=$(hyprctl cursorpos)\n' + 'cx=${pos%,*}; cy=${pos#*,}\n' + 'mon=$(hyprctl monitors -j | jq -r --argjson cx "$cx" --argjson cy "$cy" ' + "'.[] | select(($cx>=.x) and ($cx<(.x+.width)) and ($cy>=.y) and ($cy<(.y+.height))) | .name' " + '| head -n1)\n' + '[ -n "${mon:-}" ] || { echo "MONITOR_NOT_FOUND"; exit 1; }\n' + 'use_prime=0\n' + 'for v in /sys/class/drm/card*/device/vendor; do\n' + ' [ -f "$v" ] || continue\n' + ' if grep -qi "0x10de" "$v"; then\n' + ' card="$(basename "$(dirname "$(dirname "$v")")")"\n' + ' [ -e "/sys/class/drm/${card}-${mon}" ] && use_prime=1 && break\n' + ' fi\n' + 'done\n' + 'echo "${mon}:${use_prime}"';
+ monitorDetectProcess.exec({
+ "command": ["sh", "-c", script]
+ });
+ return;
+ }
+
+ launchRecorderWithSource(videoSource, false);
+ }
+
+ function launchRecorderWithSource(source, primeRun) {
+ var pattern = filenamePattern || "recording_yyyyMMdd_HHmmss";
+ var filename = expandFilenamePattern(pattern);
+ var videoDir = Settings.preprocessPath(directory);
+ if (!videoDir) {
+ videoDir = Quickshell.env("HOME") + "/Videos";
+ }
+ if (videoDir && !videoDir.endsWith("/")) {
+ videoDir += "/";
+ }
+ outputPath = videoDir + filename;
+
+ const audioFlags = (() => {
+ if (audioSource === "none") {
+ return "";
+ }
+ if (audioSource === "both") {
+ return `-ac ${audioCodec} -a "default_output|default_input"`;
+ }
+ return `-ac ${audioCodec} -a ${audioSource}`;
+ })();
+
+ var actualFrameRate = (frameRate === "custom") ? customFrameRate : frameRate;
+ var resolutionFlag = buildResolutionFlag();
+ var restoreFlag = restorePortalSession ? "-restore-portal-session yes" : "";
+ var flags = `-w ${source} -f ${actualFrameRate} -k ${videoCodec} ${audioFlags} -q ${quality} -cursor ${showCursor ? "yes" : "no"} -cr ${colorRange} ${resolutionFlag} ${restoreFlag} -o "${outputPath}"`;
+ var primePrefix = primeRun ? "prime-run " : "";
+ var command = `
+ _gpuscreenrecorder_flatpak_installed() {
+ flatpak list --app | grep -q "com.dec05eba.gpu_screen_recorder"
+ }
+ if command -v gpu-screen-recorder >/dev/null 2>&1; then
+ ${primePrefix}gpu-screen-recorder ${flags}
+ elif command -v flatpak >/dev/null 2>&1 && _gpuscreenrecorder_flatpak_installed; then
+ ${primePrefix}flatpak run --command=gpu-screen-recorder --file-forwarding com.dec05eba.gpu_screen_recorder ${flags}
+ else
+ echo "GPU_SCREEN_RECORDER_NOT_INSTALLED"
+ fi`;
+
+ // Use Process to monitor it and read stderr
+ recorderProcess.exec({
+ "command": ["sh", "-c", command]
+ });
+
+ // Start monitoring - if process ends quickly, it was likely cancelled
+ pendingTimer.running = true;
+ }
+
+ // Stop recording
+ function stopRecording() {
+ if (!isRecording && !isPending) {
+ return;
+ }
+
+ ToastService.showNotice(pluginApi.tr("messages.stopping"), outputPath, "video");
+
+ Quickshell.execDetached(["sh", "-c", "pkill -SIGINT -f '^(/nix/store/.*-gpu-screen-recorder|gpu-screen-recorder)' || pkill -SIGINT -f '^com.dec05eba.gpu_screen_recorder'"]);
+
+ isRecording = false;
+ isPending = false;
+ pendingTimer.running = false;
+ monitorTimer.running = false;
+ hasActiveRecording = false;
+
+ // Just in case, force kill after 3 seconds
+ killTimer.running = true;
+ }
+
+ // Helper function to truncate text for toast display
+ function truncateForToast(text, maxLength = 128) {
+ if (text.length <= maxLength)
+ return text;
+ return text.substring(0, maxLength) + "…";
+ }
+
+ // Helper function to check if output indicates user cancellation
+ function isCancelledByUser(stdoutText, stderrText) {
+ const stdout = String(stdoutText || "").toLowerCase();
+ const stderr = String(stderrText || "").toLowerCase();
+ const combined = stdout + " " + stderr;
+ return combined.includes("canceled by") || combined.includes("cancelled by") || combined.includes("canceled by user") || combined.includes("cancelled by user") || combined.includes("canceled by the user") || combined.includes("cancelled by the user");
+ }
+
+ // Process to run and monitor gpu-screen-recorder
+ Process {
+ id: recorderProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ onExited: function (exitCode, exitStatus) {
+ const stdout = String(recorderProcess.stdout.text || "").trim();
+ const stderr = String(recorderProcess.stderr.text || "").trim();
+ const wasCancelled = isCancelledByUser(stdout, stderr);
+
+ if (isPending) {
+ // Process ended while we were pending - likely cancelled or error
+ isPending = false;
+ pendingTimer.running = false;
+
+ // Check if gpu-screen-recorder is not installed
+ if (stdout === "GPU_SCREEN_RECORDER_NOT_INSTALLED") {
+ ToastService.showError(pluginApi.tr("messages.not-installed"), pluginApi.tr("messages.not-installed-desc"));
+ return;
+ }
+
+ // If it failed to start, show a clear error toast with stderr
+ // But don't show error if user intentionally cancelled via portal
+ if (exitCode !== 0) {
+ const filteredError = filterStderr(stderr);
+ if (filteredError.length > 0 && !wasCancelled) {
+ ToastService.showError(pluginApi.tr("messages.failed-start"), truncateForToast(filteredError));
+ Logger.e("ScreenRecorder", filteredError);
+ }
+ }
+ } else if (isRecording || hasActiveRecording) {
+ // Process ended normally while recording
+ isRecording = false;
+ monitorTimer.running = false;
+ if (exitCode === 0) {
+ // ToastService.showNotice(pluginApi.tr("messages.saved"), outputPath, "video")
+ ToastService.showNotice(pluginApi.tr("messages.saved"), outputPath, "video", 3000, pluginApi.tr("messages.open-file"), () => openFile(outputPath));
+
+ if (copyToClipboard) {
+ copyFileToClipboard(outputPath);
+ }
+ } else {
+ // Don't show error if user intentionally cancelled
+ const filteredError = filterStderr(stderr);
+ if (!wasCancelled) {
+ if (filteredError.length > 0) {
+ ToastService.showError(pluginApi.tr("messages.failed-start"), truncateForToast(filteredError));
+ Logger.e("ScreenRecorder", filteredError);
+ } else if (exitCode !== 0) {
+ ToastService.showError(pluginApi.tr("messages.failed-start"), pluginApi.tr("messages.failed-general"));
+ }
+ }
+ }
+ hasActiveRecording = false;
+ } else if (!isPending && exitCode === 0 && outputPath) {
+ // Fallback: if process exited successfully with an outputPath, handle it
+ // ToastService.showNotice(pluginApi.tr("messages.saved"), outputPath, "video")
+ ToastService.showNotice(pluginApi.tr("messages.saved"), outputPath, "video", 3000, pluginApi.tr("messages.open-file"), () => openFile(outputPath));
+
+ if (copyToClipboard) {
+ copyFileToClipboard(outputPath);
+ }
+ }
+ }
+ }
+
+ // Pre-flight check for xdg-desktop-portal
+ Process {
+ id: portalCheckProcess
+ onExited: function (exitCode, exitStatus) {
+ if (exitCode === 0) {
+ // Portals available, proceed to launch
+ launchRecorder();
+ } else {
+ isPending = false;
+ hasActiveRecording = false;
+ ToastService.showError(pluginApi.tr("messages.no-portals"), pluginApi.tr("messages.no-portals-desc"));
+ }
+ }
+ }
+
+ // Detect focused monitor on Hyprland
+ Process {
+ id: monitorDetectProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ onExited: function (exitCode, exitStatus) {
+ const output = String(monitorDetectProcess.stdout.text || "").trim();
+
+ if (exitCode !== 0 || output === "MONITOR_NOT_FOUND" || !output) {
+ isPending = false;
+ hasActiveRecording = false;
+ ToastService.showError(pluginApi.tr("messages.failed-start"), pluginApi.tr("messages.monitor-not-found"));
+ return;
+ }
+
+ // Parse "MONITOR_NAME:USE_PRIME" format
+ const parts = output.split(":");
+ const monitorName = parts[0];
+ const primeRun = parts.length > 1 && parts[1] === "1";
+
+ detectedMonitor = monitorName;
+ usePrimeRun = primeRun;
+
+ Logger.i("ScreenRecorder", "Detected monitor: " + monitorName + (primeRun ? " (prime-run)" : ""));
+ launchRecorderWithSource(monitorName, primeRun);
+ }
+ }
+
+ // Process to copy file to clipboard
+ Process {
+ id: copyToClipboardProcess
+ onExited: function (exitCode, exitStatus) {
+ if (exitCode !== 0) {
+ Logger.e("ScreenRecorder", "Failed to copy file to clipboard, exit code:", exitCode);
+ }
+ }
+ }
+
+ Timer {
+ id: pendingTimer
+ interval: 2000 // Wait 2 seconds to see if process stays alive
+ running: false
+ repeat: false
+ onTriggered: {
+ if (isPending && recorderProcess.running) {
+ // Process is still running after 2 seconds - assume recording started successfully
+ isPending = false;
+ isRecording = true;
+ hasActiveRecording = true;
+ monitorTimer.running = true;
+ } else if (isPending) {
+ // Process not running anymore - was cancelled or failed
+ isPending = false;
+ }
+ }
+ }
+
+ // Monitor timer to periodically check if we're still recording
+ Timer {
+ id: monitorTimer
+ interval: 2000
+ running: false
+ repeat: true
+ onTriggered: {
+ if (!recorderProcess.running && isRecording) {
+ isRecording = false;
+ running = false;
+ }
+ }
+ }
+
+ Timer {
+ id: killTimer
+ interval: 3000
+ running: false
+ repeat: false
+ onTriggered: {
+ Quickshell.execDetached(["sh", "-c", "pkill -9 -f '^(/nix/store/.*-gpu-screen-recorder|gpu-screen-recorder)' 2>/dev/null || pkill -9 -f '^com.dec05eba.gpu_screen_recorder' 2>/dev/null || true"]);
+ }
+ }
+
+ // ─── Replay Buffer ───────────────────────────────────────────────────
+
+ function toggleReplay() {
+ (isReplaying || isReplayPending) ? stopReplay() : startReplay();
+ }
+
+ function startReplay() {
+ if (!isAvailable) return;
+ if (isReplaying || isReplayPending) return;
+ if (!replayEnabled) return;
+
+ isReplayPending = true;
+
+ replayPortalCheckProcess.exec({
+ "command": ["sh", "-c", "pidof xdg-desktop-portal >/dev/null 2>&1 && (pidof xdg-desktop-portal-wlr >/dev/null 2>&1 || pidof xdg-desktop-portal-hyprland >/dev/null 2>&1 || pidof xdg-desktop-portal-gnome >/dev/null 2>&1 || pidof xdg-desktop-portal-kde >/dev/null 2>&1)"]
+ });
+ }
+
+ function launchReplay() {
+ // If focused-monitor is selected, detect monitor first
+ if (videoSource === "focused-monitor" && CompositorService.isHyprland) {
+ var script = 'set -euo pipefail\n' + 'pos=$(hyprctl cursorpos)\n' + 'cx=${pos%,*}; cy=${pos#*,}\n' + 'mon=$(hyprctl monitors -j | jq -r --argjson cx "$cx" --argjson cy "$cy" ' + "'.[] | select(($cx>=.x) and ($cx<(.x+.width)) and ($cy>=.y) and ($cy<(.y+.height))) | .name' " + '| head -n1)\n' + '[ -n "${mon:-}" ] || { echo "MONITOR_NOT_FOUND"; exit 1; }\n' + 'use_prime=0\n' + 'for v in /sys/class/drm/card*/device/vendor; do\n' + ' [ -f "$v" ] || continue\n' + ' if grep -qi "0x10de" "$v"; then\n' + ' card="$(basename "$(dirname "$(dirname "$v")")")"\n' + ' [ -e "/sys/class/drm/${card}-${mon}" ] && use_prime=1 && break\n' + ' fi\n' + 'done\n' + 'echo "${mon}:${use_prime}"';
+ replayMonitorDetectProcess.exec({
+ "command": ["sh", "-c", script]
+ });
+ return;
+ }
+
+ launchReplayWithSource(videoSource, false);
+ }
+
+ function launchReplayWithSource(source, primeRun) {
+ var videoDir = Settings.preprocessPath(directory);
+ if (!videoDir) {
+ videoDir = Quickshell.env("HOME") + "/Videos";
+ }
+ // Ensure directory ends with / for gpu-screen-recorder
+ if (videoDir && !videoDir.endsWith("/")) {
+ videoDir += "/";
+ }
+
+ var actualDuration = (replayDuration === "custom") ? customReplayDuration : replayDuration;
+ var actualFrameRate = (frameRate === "custom") ? customFrameRate : frameRate;
+ var resolutionFlag = buildResolutionFlag();
+
+ const audioFlags = (() => {
+ if (audioSource === "none") {
+ return "";
+ }
+ if (audioSource === "both") {
+ return `-ac ${audioCodec} -a "default_output|default_input"`;
+ }
+ return `-ac ${audioCodec} -a ${audioSource}`;
+ })();
+
+ var restoreFlag = restorePortalSession ? "-restore-portal-session yes" : "";
+ var flags = `-w ${source} -c mp4 -f ${actualFrameRate} -k ${videoCodec} ${audioFlags} -q ${quality} -cursor ${showCursor ? "yes" : "no"} -cr ${colorRange} ${resolutionFlag} -r ${actualDuration} -replay-storage ${replayStorage} ${restoreFlag} -o "${videoDir}"`;
+ var primePrefix = primeRun ? "prime-run " : "";
+ var command = `
+ _gpuscreenrecorder_flatpak_installed() {
+ flatpak list --app | grep -q "com.dec05eba.gpu_screen_recorder"
+ }
+ if command -v gpu-screen-recorder >/dev/null 2>&1; then
+ ${primePrefix}gpu-screen-recorder ${flags}
+ elif command -v flatpak >/dev/null 2>&1 && _gpuscreenrecorder_flatpak_installed; then
+ ${primePrefix}flatpak run --command=gpu-screen-recorder --file-forwarding com.dec05eba.gpu_screen_recorder ${flags}
+ else
+ echo "GPU_SCREEN_RECORDER_NOT_INSTALLED"
+ fi`;
+
+ replayProcess.exec({
+ "command": ["sh", "-c", command]
+ });
+
+ replayPendingTimer.running = true;
+ }
+
+ function stopReplay() {
+ if (!isReplaying && !isReplayPending) return;
+
+ // Send SIGINT to stop the replay daemon
+ Quickshell.execDetached(["sh", "-c", "pkill -SIGINT -f '^(/nix/store/.*-gpu-screen-recorder|gpu-screen-recorder).*-r ' || pkill -SIGINT -f '^com.dec05eba.gpu_screen_recorder.*-r '"]);
+
+ isReplaying = false;
+ isReplayPending = false;
+ replayPendingTimer.running = false;
+ replayMonitorTimer.running = false;
+
+ ToastService.showNotice(pluginApi.tr("messages.replay-stopped"), "", "info");
+
+ // Force kill after 3 seconds, just in case
+ replayKillTimer.running = true;
+ }
+
+ function saveReplay() {
+ if (!isReplaying) return;
+
+ // Send SIGUSR1 to save the replay buffer
+ Quickshell.execDetached(["sh", "-c", "pkill -SIGUSR1 -f '^(/nix/store/.*-gpu-screen-recorder|gpu-screen-recorder).*-r ' || pkill -SIGUSR1 -f '^com.dec05eba.gpu_screen_recorder.*-r '"]);
+ }
+
+ // Replay Process
+ Process {
+ id: replayProcess
+ stdout: SplitParser {
+ onRead: data => {
+ // gpu-screen-recorder outputs the saved file path to stdout on each SIGUSR1 save
+ var savedPath = String(data).trim();
+ if (savedPath && savedPath.length > 0 && !savedPath.startsWith("GPU_SCREEN_RECORDER")) {
+ ToastService.showNotice(
+ pluginApi.tr("messages.replay-saved"),
+ savedPath, "video", 3000,
+ pluginApi.tr("messages.open-file"),
+ () => openFile(savedPath)
+ );
+
+ if (copyToClipboard) {
+ copyFileToClipboard(savedPath);
+ }
+ }
+ }
+ }
+ stderr: StdioCollector {}
+ onExited: function(exitCode, exitStatus) {
+ const stderr = String(replayProcess.stderr.text || "").trim();
+ const wasCancelled = isCancelledByUser("", stderr);
+ const filteredError = filterStderr(stderr);
+
+ if (isReplayPending) {
+ isReplayPending = false;
+ replayPendingTimer.running = false;
+
+ if (exitCode !== 0 && filteredError.length > 0 && !wasCancelled) {
+ ToastService.showError(pluginApi.tr("messages.replay-failed"), truncateForToast(filteredError));
+ Logger.e("ScreenRecorder", "Replay: " + filteredError);
+ }
+ } else if (isReplaying) {
+ isReplaying = false;
+ replayMonitorTimer.running = false;
+
+ if (exitCode !== 0 && !wasCancelled) {
+ if (filteredError.length > 0) {
+ ToastService.showError(pluginApi.tr("messages.replay-failed"), truncateForToast(filteredError));
+ Logger.e("ScreenRecorder", "Replay: " + filteredError);
+ }
+ }
+ }
+ }
+ }
+
+ // Filter stderr to only include actual errors (starting with gsr error: or gsr fatal:)
+ function filterStderr(text) {
+ if (!text) return "";
+ const lines = text.split("\n");
+ const errorLines = lines.filter(line => {
+ const lower = line.toLowerCase();
+ if (lower.includes("gsr info:") || lower.includes("gsr notice:") || lower.includes("(error: none)")) return false;
+ return lower.includes("gsr error:") || lower.includes("gsr fatal:") || lower.includes("failed to") || lower.includes("error:");
+ });
+ if (errorLines.length === 0) return "";
+ return errorLines.join("\n").trim();
+ }
+
+ // Portal check for replay
+ Process {
+ id: replayPortalCheckProcess
+ onExited: function(exitCode, exitStatus) {
+ if (exitCode === 0) {
+ launchReplay();
+ } else {
+ isReplayPending = false;
+ ToastService.showError(pluginApi.tr("messages.no-portals"), pluginApi.tr("messages.no-portals-desc"));
+ }
+ }
+ }
+
+ // Monitor detection for replay on Hyprland
+ Process {
+ id: replayMonitorDetectProcess
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ onExited: function(exitCode, exitStatus) {
+ const output = String(replayMonitorDetectProcess.stdout.text || "").trim();
+
+ if (exitCode !== 0 || output === "MONITOR_NOT_FOUND" || !output) {
+ isReplayPending = false;
+ ToastService.showError(pluginApi.tr("messages.replay-failed"), pluginApi.tr("messages.monitor-not-found"));
+ return;
+ }
+
+ const parts = output.split(":");
+ const monitorName = parts[0];
+ const primeRun = parts.length > 1 && parts[1] === "1";
+
+ Logger.i("ScreenRecorder", "Replay: Detected monitor: " + monitorName + (primeRun ? " (prime-run)" : ""));
+ launchReplayWithSource(monitorName, primeRun);
+ }
+ }
+
+ Timer {
+ id: replayPendingTimer
+ interval: 2000
+ running: false
+ repeat: false
+ onTriggered: {
+ if (root.isReplayPending && replayProcess.running) {
+ root.isReplayPending = false;
+ root.isReplaying = true;
+ replayMonitorTimer.running = true;
+ ToastService.showNotice(pluginApi.tr("messages.replay-started"), "", "info");
+ } else if (root.isReplayPending) {
+ root.isReplayPending = false;
+ }
+ }
+ }
+
+ Timer {
+ id: replayMonitorTimer
+ interval: 2000
+ running: false
+ repeat: true
+ onTriggered: {
+ if (!replayProcess.running && root.isReplaying) {
+ root.isReplaying = false;
+ running = false;
+ }
+ }
+ }
+
+ Timer {
+ id: replayKillTimer
+ interval: 3000
+ running: false
+ repeat: false
+ onTriggered: {
+ Quickshell.execDetached(["sh", "-c", "pkill -9 -f '^(/nix/store/.*-gpu-screen-recorder|gpu-screen-recorder).*-r ' 2>/dev/null || pkill -9 -f '^com.dec05eba.gpu_screen_recorder.*-r ' 2>/dev/null || true"]);
+ }
+ }
+
+}
diff --git a/.config/noctalia/plugins/screen-recorder/README.md b/.config/noctalia/plugins/screen-recorder/README.md
new file mode 100644
index 0000000..5bc4479
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/README.md
@@ -0,0 +1,143 @@
+# Screen Recorder Plugin
+
+Hardware-accelerated screen recording for Noctalia using [gpu-screen-recorder](https://git.dec05eba.com/gpu-screen-recorder/about/).
+
+## Features
+
+- Hardware-accelerated screen recording
+- **Replay buffer** — continuously capture and save the last N seconds on demand
+- Customizable video codecs (H264, HEVC, AV1, VP8, VP9, HDR variants)
+- Audio recording with multiple sources (system output, microphone, both, or none)
+- Adjustable frame rates (30-240 FPS)
+- Configurable output resolution (Full HD, WUXGA, 4K, QHD, HD, or original)
+- Configurable output directory
+- Optional clipboard copy after recording
+- Optional cursor recording
+- Multiple quality presets
+
+## Requirements
+
+- **gpu-screen-recorder** - The screen recording backend
+ - Install via package manager: `gpu-screen-recorder`
+- **xdg-desktop-portal** and a compositor portal backend (wlr/hyprland/gnome etc)
+
+## Installation
+
+1. Copy this plugin to your Noctalia plugins directory:
+ ```bash
+ cp -r screen-recorder ~/.config/noctalia/plugins/
+ ```
+
+2. Add the widget to your bar through Noctalia settings
+
+## Usage
+
+### Bar Widget
+
+- **Left Click**: Start/stop recording
+- **Right Click**: Open plugin settings
+
+### Settings
+
+Configure the plugin through the settings panel:
+
+- **Output Folder**: Where recordings will be saved (defaults to `~/Videos`)
+- **Filename Pattern**: Pattern for generated filenames. Supports standard Qt date format tokens (e.g., `yyyy`, `MM`, `dd` etc.) and `unix` for Unix timestamp.
+- **Video Source**: Choose between Portal (recommended) or Screen
+- **Frame Rate**: Target FPS (30, 60, 100, 120, 144, 165, 240)
+- **Video Quality**: Medium, High, Very High, or Ultra
+- **Video Codec**: H264, HEVC, AV1, VP8, VP9 (+ HDR variants for screen source)
+- **Color Range**: Limited (recommended) or Full
+- **Resolution**: Output resolution limit (1920x1080, 1920x1200, 2560x1440, 3840x2160, 1280x720, or Original)
+- **Audio Source**: None, System Output, Microphone, or Both
+- **Audio Codec**: Opus (recommended) or AAC
+- **Show Cursor**: Include mouse cursor in recording
+- **Copy to Clipboard**: Automatically copy file after recording
+
+#### Replay
+
+- **Enable Replay Buffer**: Toggle the replay buffer feature on/off
+- **Replay Duration**: Buffer length (15s, 30s, 60s, 2 min, 5 min, or custom)
+- **Replay Storage**: Store buffer in RAM (faster, recommended) or Disk
+
+### IPC Commands
+
+Control the screen recorder via IPC for keybindings or scripts:
+
+```bash
+# Toggle recording on/off
+qs -c noctalia-shell ipc call plugin:screen-recorder toggle
+
+# Explicitly start recording
+qs -c noctalia-shell ipc call plugin:screen-recorder start
+
+# Explicitly stop recording
+qs -c noctalia-shell ipc call plugin:screen-recorder stop
+
+# Start the replay buffer
+qs -c noctalia-shell ipc call plugin:screen-recorder startReplay
+
+# Save the replay buffer (last N seconds)
+qs -c noctalia-shell ipc call plugin:screen-recorder saveReplay
+
+# Stop the replay buffer
+qs -c noctalia-shell ipc call plugin:screen-recorder stopReplay
+
+# Toggle replay buffer on/off
+qs -c noctalia-shell ipc call plugin:screen-recorder toggleReplay
+```
+
+## Video Codecs
+
+- **H264**: Most compatible, good quality, works everywhere
+- **HEVC (H.265)**: Better compression than H264, smaller files
+- **AV1**: Best compression, smallest files, newer codec
+- **VP8/VP9**: Open-source alternatives
+- **HDR variants**: Available when using "Screen" source for high dynamic range content
+
+## Audio Sources
+
+- **None**: No audio recording
+- **System Output**: Capture system sounds (what you hear)
+- **Microphone Input**: Capture from microphone
+- **Both**: Record both system and microphone audio
+
+## Troubleshooting
+
+### "Screen recorder not installed" error
+Install gpu-screen-recorder:
+```bash
+# Arch Linux
+sudo pacman -S gpu-screen-recorder
+
+# Or via Flatpak
+flatpak install com.dec05eba.gpu_screen_recorder
+```
+
+### "Desktop portals not running" error
+Ensure xdg-desktop-portal and a compositor portal are running:
+```bash
+# Check if portals are running
+pidof xdg-desktop-portal
+pidof xdg-desktop-portal-wlr # or -hyprland, -gnome, -kde
+
+# Install if needed (example for Arch)
+sudo pacman -S xdg-desktop-portal xdg-desktop-portal-wlr
+```
+
+### Recording fails with artifacts
+Try changing the video source from "Portal" to "Screen" in settings.
+
+### Recording stops immediately
+- Check if the output directory exists and is writable
+- Ensure gpu-screen-recorder has necessary permissions
+- Check Noctalia logs for detailed error messages
+
+## License
+
+MIT License
+
+## Credits
+
+- Uses [gpu-screen-recorder](https://git.dec05eba.com/gpu-screen-recorder/about/) by dec05eba
+- Part of the [Noctalia](https://github.com/noctalia-dev/noctalia-shell) plugin ecosystem
diff --git a/.config/noctalia/plugins/screen-recorder/Settings.qml b/.config/noctalia/plugins/screen-recorder/Settings.qml
new file mode 100644
index 0000000..2d254ec
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/Settings.qml
@@ -0,0 +1,556 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+import qs.Services.Compositor
+
+ColumnLayout {
+ id: root
+ spacing: Style.marginL
+
+ property var pluginApi: null
+
+ property bool editHideInactive: pluginApi?.pluginSettings?.hideInactive ?? pluginApi?.manifest?.metadata?.defaultSettings?.hideInactive ?? false
+
+ property string editIconColor: pluginApi?.pluginSettings?.iconColor ?? pluginApi?.manifest?.metadata?.defaultSettings?.iconColor ?? "none"
+
+ property string editDirectory: pluginApi?.pluginSettings?.directory || pluginApi?.manifest?.metadata?.defaultSettings?.directory || ""
+
+ property string editFilenamePattern: pluginApi?.pluginSettings?.filenamePattern || pluginApi?.manifest?.metadata?.defaultSettings?.filenamePattern || "recording_yyyyMMdd_HHmmss"
+
+ // Migrate legacy frame rates to "custom"
+ readonly property var _validFrameRates: ["30", "60", "120", "custom"]
+ readonly property string _rawFrameRate:
+ pluginApi?.pluginSettings?.frameRate ||
+ pluginApi?.manifest?.metadata?.defaultSettings?.frameRate ||
+ "60"
+
+ property string editFrameRate:
+ _validFrameRates.includes(_rawFrameRate) ? _rawFrameRate : "custom"
+
+ property string editCustomFrameRate:
+ _validFrameRates.includes(_rawFrameRate)
+ ? (pluginApi?.pluginSettings?.customFrameRate ||
+ pluginApi?.manifest?.metadata?.defaultSettings?.customFrameRate ||
+ "60")
+ : _rawFrameRate
+
+ property string editAudioCodec:
+ pluginApi?.pluginSettings?.audioCodec ||
+ pluginApi?.manifest?.metadata?.defaultSettings?.audioCodec ||
+ "opus"
+
+ property string editVideoCodec: pluginApi?.pluginSettings?.videoCodec || pluginApi?.manifest?.metadata?.defaultSettings?.videoCodec || "h264"
+
+ property string editQuality: pluginApi?.pluginSettings?.quality || pluginApi?.manifest?.metadata?.defaultSettings?.quality || "very_high"
+
+ property string editColorRange: pluginApi?.pluginSettings?.colorRange || pluginApi?.manifest?.metadata?.defaultSettings?.colorRange || "limited"
+
+ property bool editShowCursor: pluginApi?.pluginSettings?.showCursor ?? pluginApi?.manifest?.metadata?.defaultSettings?.showCursor ?? true
+
+ property bool editCopyToClipboard: pluginApi?.pluginSettings?.copyToClipboard ?? pluginApi?.manifest?.metadata?.defaultSettings?.copyToClipboard ?? false
+
+ property string editAudioSource: pluginApi?.pluginSettings?.audioSource || pluginApi?.manifest?.metadata?.defaultSettings?.audioSource || "default_output"
+
+ property string editVideoSource: pluginApi?.pluginSettings?.videoSource || pluginApi?.manifest?.metadata?.defaultSettings?.videoSource || "portal"
+
+ property string editResolution: pluginApi?.pluginSettings?.resolution || pluginApi?.manifest?.metadata?.defaultSettings?.resolution || "original"
+
+ property bool editRestorePortalSession: pluginApi?.pluginSettings?.restorePortalSession ?? pluginApi?.manifest?.metadata?.defaultSettings?.restorePortalSession ?? false
+
+ // Replay settings
+ property bool editReplayEnabled: pluginApi?.pluginSettings?.replayEnabled ?? pluginApi?.manifest?.metadata?.defaultSettings?.replayEnabled ?? false
+
+ readonly property var _validReplayDurations: ["15", "30", "60", "120", "300", "custom"]
+ readonly property string _rawReplayDuration:
+ pluginApi?.pluginSettings?.replayDuration ||
+ pluginApi?.manifest?.metadata?.defaultSettings?.replayDuration ||
+ "30"
+
+ property string editReplayDuration:
+ _validReplayDurations.includes(_rawReplayDuration) ? _rawReplayDuration : "custom"
+
+ property string editCustomReplayDuration:
+ _validReplayDurations.includes(_rawReplayDuration)
+ ? (pluginApi?.pluginSettings?.customReplayDuration ||
+ pluginApi?.manifest?.metadata?.defaultSettings?.customReplayDuration ||
+ "30")
+ : _rawReplayDuration
+
+ property string editReplayStorage: pluginApi?.pluginSettings?.replayStorage || pluginApi?.manifest?.metadata?.defaultSettings?.replayStorage || "ram"
+
+
+
+ function saveSettings() {
+ if (!pluginApi || !pluginApi.pluginSettings) {
+ Logger.e("ScreenRecorder", "Cannot save: pluginApi or pluginSettings is null");
+ return;
+ }
+
+ // Core settings
+ pluginApi.pluginSettings.hideInactive = root.editHideInactive
+ pluginApi.pluginSettings.iconColor = root.editIconColor
+ pluginApi.pluginSettings.directory = root.editDirectory
+ pluginApi.pluginSettings.filenamePattern = root.editFilenamePattern
+ pluginApi.pluginSettings.frameRate = root.editFrameRate
+ pluginApi.pluginSettings.customFrameRate = root.editCustomFrameRate
+ pluginApi.pluginSettings.audioCodec = root.editAudioCodec
+ pluginApi.pluginSettings.videoCodec = root.editVideoCodec
+ pluginApi.pluginSettings.quality = root.editQuality
+ pluginApi.pluginSettings.colorRange = root.editColorRange
+ pluginApi.pluginSettings.showCursor = root.editShowCursor
+ pluginApi.pluginSettings.copyToClipboard = root.editCopyToClipboard
+ pluginApi.pluginSettings.audioSource = root.editAudioSource
+ pluginApi.pluginSettings.videoSource = root.editVideoSource
+ pluginApi.pluginSettings.resolution = root.editResolution
+ pluginApi.pluginSettings.restorePortalSession = root.editRestorePortalSession
+
+ // Replay settings
+ pluginApi.pluginSettings.replayEnabled = root.editReplayEnabled
+ pluginApi.pluginSettings.replayDuration = root.editReplayDuration
+ pluginApi.pluginSettings.customReplayDuration = root.editCustomReplayDuration
+ pluginApi.pluginSettings.replayStorage = root.editReplayStorage
+
+
+ pluginApi.saveSettings();
+
+ Logger.i("ScreenRecorder", "Settings saved successfully");
+ }
+ // Icon Color
+ NComboBox {
+ label: I18n.tr("common.select-icon-color")
+ description: I18n.tr("common.select-color-description")
+ model: Color.colorKeyModel
+ currentKey: root.editIconColor
+ onSelected: key => root.editIconColor = key
+ minimumWidth: 200
+ }
+
+ NTextInputButton {
+ label: pluginApi.tr("settings.general.output-folder")
+ description: pluginApi.tr("settings.general.output-folder-description")
+ placeholderText: Quickshell.env("HOME") + "/Videos"
+ text: root.editDirectory
+ buttonIcon: "folder-open"
+ buttonTooltip: pluginApi.tr("settings.general.output-folder")
+ onInputEditingFinished: root.editDirectory = text
+ onButtonClicked: folderPicker.openFilePicker()
+ }
+
+ // Filename Pattern
+ NTextInput {
+ label: pluginApi?.tr("settings.filename-pattern.label") || "Filename pattern"
+ description: pluginApi?.tr("settings.filename-pattern.description") || "Pattern for generated filenames. Supported: yyyy, yy, MM, M, dd, d, HH, H, mm, m, ss, s (e.g., mycool-recording_yyyyMMdd_HHmmss)"
+ placeholderText: "recording_yyyyMMdd_HHmmss"
+ text: root.editFilenamePattern
+ onTextChanged: root.editFilenamePattern = text
+ Layout.fillWidth: true
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ // Show Cursor
+ NToggle {
+ label: pluginApi.tr("settings.general.show-cursor")
+ description: pluginApi.tr("settings.general.show-cursor-description")
+ checked: root.editShowCursor
+ onToggled: c => root.editShowCursor = c
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.showCursor ?? true
+ }
+
+ // Copy to Clipboard
+ NToggle {
+ label: pluginApi.tr("settings.general.copy-to-clipboard")
+ description: pluginApi.tr("settings.general.copy-to-clipboard-description")
+ checked: root.editCopyToClipboard
+ onToggled: c => root.editCopyToClipboard = c
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.copyToClipboard ?? false
+ }
+
+ // Hide When Inactive Toggle
+ NToggle {
+ label: pluginApi.tr("settings.general.hide-when-inactive")
+ description: pluginApi.tr("settings.general.hide-when-inactive-description")
+ checked: root.editHideInactive
+ onToggled: c => root.editHideInactive = c
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.hideInactive ?? false
+ }
+
+ // Restore Portal Session
+ NToggle {
+ label: pluginApi.tr("settings.general.restore-portal-session")
+ description: pluginApi.tr("settings.general.restore-portal-session-description")
+ checked: root.editRestorePortalSession
+ onToggled: c => root.editRestorePortalSession = c
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.restorePortalSession ?? false
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ // Replay Settings
+ ColumnLayout {
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ NToggle {
+ label: pluginApi.tr("settings.replay.enable")
+ description: pluginApi.tr("settings.replay.enable-desc")
+ checked: root.editReplayEnabled
+ onToggled: c => root.editReplayEnabled = c
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.replayEnabled ?? false
+ }
+
+ NComboBox {
+ visible: root.editReplayEnabled
+ label: pluginApi.tr("settings.replay.duration")
+ description: pluginApi.tr("settings.replay.duration-desc")
+ model: [
+ { "key": "15", "name": "15s" },
+ { "key": "30", "name": "30s" },
+ { "key": "60", "name": "60s" },
+ { "key": "120", "name": "2 min" },
+ { "key": "300", "name": "5 min" },
+ { "key": "custom", "name": pluginApi.tr("settings.video.frame-rate-custom") }
+ ]
+ currentKey: root.editReplayDuration
+ onSelected: key => root.editReplayDuration = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.replayDuration || "30"
+ }
+
+ NTextInput {
+ visible: root.editReplayEnabled && root.editReplayDuration === "custom"
+ label: pluginApi.tr("settings.replay.custom-duration")
+ description: pluginApi.tr("settings.replay.custom-duration-desc")
+ placeholderText: "30"
+ text: root.editCustomReplayDuration
+ onTextChanged: {
+ var numeric = text.replace(/[^0-9]/g, '')
+ if (numeric !== text) {
+ text = numeric
+ }
+ if (numeric) {
+ root.editCustomReplayDuration = numeric
+ }
+ }
+ Layout.fillWidth: true
+ }
+
+ NComboBox {
+ visible: root.editReplayEnabled
+ label: pluginApi.tr("settings.replay.storage")
+ description: pluginApi.tr("settings.replay.storage-desc")
+ model: [
+ { "key": "ram", "name": pluginApi.tr("settings.replay.storage-ram") },
+ { "key": "disk", "name": pluginApi.tr("settings.replay.storage-disk") }
+ ]
+ currentKey: root.editReplayStorage
+ onSelected: key => root.editReplayStorage = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.replayStorage || "ram"
+ }
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ // Video Settings
+ ColumnLayout {
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ // Source
+ NComboBox {
+ label: pluginApi.tr("settings.video.source")
+ description: pluginApi.tr("settings.video.source-desc")
+ model: {
+ let options = [
+ {
+ "key": "portal",
+ "name": pluginApi.tr("settings.video.sources-portal")
+ },
+ {
+ "key": "screen",
+ "name": pluginApi.tr("settings.video.sources-screen")
+ }
+ ];
+ if (CompositorService.isHyprland) {
+ options.push({
+ "key": "focused-monitor",
+ "name": pluginApi.tr("settings.video.sources-focused-monitor")
+ });
+ }
+ return options;
+ }
+ currentKey: root.editVideoSource
+ onSelected: key => root.editVideoSource = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.videoSource || "portal"
+ }
+
+ // Frame Rate
+ NComboBox {
+ label: pluginApi.tr("settings.video.frame-rate")
+ description: pluginApi.tr("settings.video.frame-rate-desc")
+ model: [
+ {
+ "key": "30",
+ "name": "30 FPS"
+ },
+ {
+ "key": "60",
+ "name": "60 FPS"
+ },
+ {
+ "key": "120",
+ "name": "120 FPS"
+ },
+ {
+ "key": "custom",
+ "name": pluginApi.tr("settings.video.frame-rate-custom")
+ }
+ ]
+ currentKey: root.editFrameRate
+ onSelected: key => root.editFrameRate = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.frameRate || "60"
+ }
+
+ // Custom Frame Rate
+ NTextInput {
+ visible: root.editFrameRate === "custom"
+ label: pluginApi.tr("settings.video.custom-frame-rate")
+ description: pluginApi.tr("settings.video.custom-frame-rate-desc")
+ placeholderText: "60"
+ text: root.editCustomFrameRate
+ onTextChanged: {
+ // Only allow numeric values
+ var numeric = text.replace(/[^0-9]/g, '')
+ if (numeric !== text) {
+ text = numeric
+ }
+ if (numeric) {
+ root.editCustomFrameRate = numeric
+ }
+ }
+ Layout.fillWidth: true
+ }
+
+ // Video Quality
+ NComboBox {
+ label: pluginApi.tr("settings.video.quality")
+ description: pluginApi.tr("settings.video.quality-desc")
+ model: [
+ {
+ "key": "medium",
+ "name": pluginApi.tr("settings.video.quality-medium")
+ },
+ {
+ "key": "high",
+ "name": pluginApi.tr("settings.video.quality-high")
+ },
+ {
+ "key": "very_high",
+ "name": pluginApi.tr("settings.video.quality-very-high")
+ },
+ {
+ "key": "ultra",
+ "name": pluginApi.tr("settings.video.quality-ultra")
+ }
+ ]
+ currentKey: root.editQuality
+ onSelected: key => root.editQuality = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.quality || "very_high"
+ }
+
+ // Video Codec
+ NComboBox {
+ label: pluginApi.tr("settings.video.codec")
+ description: pluginApi.tr("settings.video.codec-desc")
+ model: {
+ let options = [
+ {
+ "key": "h264",
+ "name": "H264"
+ },
+ {
+ "key": "hevc",
+ "name": "HEVC"
+ },
+ {
+ "key": "av1",
+ "name": "AV1"
+ },
+ {
+ "key": "vp8",
+ "name": "VP8"
+ },
+ {
+ "key": "vp9",
+ "name": "VP9"
+ }
+ ];
+ // Only add HDR options if source is 'screen' or 'focused-monitor'
+ if (root.editVideoSource === "screen" || root.editVideoSource === "focused-monitor") {
+ options.push({
+ "key": "hevc_hdr",
+ "name": "HEVC HDR"
+ });
+ options.push({
+ "key": "av1_hdr",
+ "name": "AV1 HDR"
+ });
+ }
+ return options;
+ }
+ currentKey: root.editVideoCodec
+ onSelected: key => {
+ root.editVideoCodec = key;
+ // If an HDR codec is selected, change the colorRange to full
+ if (key.includes("_hdr")) {
+ root.editColorRange = "full";
+ }
+ }
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.videoCodec || "h264"
+
+ Connections {
+ target: root
+ function onEditVideoSourceChanged() {
+ if (root.editVideoSource !== "screen" && root.editVideoSource !== "focused-monitor" && (root.editVideoCodec === "av1_hdr" || root.editVideoCodec === "hevc_hdr")) {
+ root.editVideoCodec = "h264";
+ }
+ }
+ }
+ }
+
+ // Color Range
+ NComboBox {
+ label: pluginApi.tr("settings.video.color-range")
+ description: pluginApi.tr("settings.video.color-range-desc")
+ model: [
+ {
+ "key": "limited",
+ "name": pluginApi.tr("settings.video.color-range-limited")
+ },
+ {
+ "key": "full",
+ "name": pluginApi.tr("settings.video.color-range-full")
+ }
+ ]
+ currentKey: root.editColorRange
+ onSelected: key => root.editColorRange = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.colorRange || "limited"
+ }
+
+ // Resolution
+ NComboBox {
+ label: pluginApi.tr("settings.video.resolution")
+ description: pluginApi.tr("settings.video.resolution-desc")
+ model: [
+ {
+ "key": "original",
+ "name": pluginApi.tr("settings.video.resolution-original")
+ },
+ {
+ "key": "1920x1080",
+ "name": "1920x1080 (Full HD)"
+ },
+ {
+ "key": "1920x1200",
+ "name": "1920x1200 (WUXGA)"
+ },
+ {
+ "key": "2560x1440",
+ "name": "2560x1440 (QHD)"
+ },
+ {
+ "key": "3840x2160",
+ "name": "3840x2160 (4K)"
+ },
+ {
+ "key": "1280x720",
+ "name": "1280x720 (HD)"
+ }
+ ]
+ currentKey: root.editResolution
+ onSelected: key => root.editResolution = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.resolution || "original"
+ }
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ // Audio Settings
+ ColumnLayout {
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ // Audio Source
+ NComboBox {
+ label: pluginApi.tr("settings.audio.source")
+ description: pluginApi.tr("settings.audio.source-desc")
+ model: [
+ {
+ "key": "none",
+ "name": pluginApi.tr("settings.audio.audio-sources-none")
+ },
+ {
+ "key": "default_output",
+ "name": pluginApi.tr("settings.audio.audio-sources-system-output")
+ },
+ {
+ "key": "default_input",
+ "name": pluginApi.tr("settings.audio.audio-sources-microphone-input")
+ },
+ {
+ "key": "both",
+ "name": pluginApi.tr("settings.audio.audio-sources-both")
+ }
+ ]
+ currentKey: root.editAudioSource
+ onSelected: key => root.editAudioSource = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.audioSource || "default_output"
+ }
+
+ // Audio Codec
+ NComboBox {
+ label: pluginApi.tr("settings.audio.codec")
+ description: pluginApi.tr("settings.audio.codec-desc")
+ model: [
+ {
+ "key": "opus",
+ "name": "Opus"
+ },
+ {
+ "key": "aac",
+ "name": "AAC"
+ }
+ ]
+ currentKey: root.editAudioCodec
+ onSelected: key => root.editAudioCodec = key
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.audioCodec || "opus"
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ NFilePicker {
+ id: folderPicker
+ selectionMode: "folders"
+ title: pluginApi.tr("settings.general.output-folder")
+ initialPath: root.editDirectory || Quickshell.env("HOME") + "/Videos"
+ onAccepted: paths => {
+ if (paths.length > 0) {
+ root.editDirectory = paths[0];
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/de.json b/.config/noctalia/plugins/screen-recorder/i18n/de.json
new file mode 100644
index 0000000..6907c0a
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/de.json
@@ -0,0 +1,99 @@
+{
+ "description": "Hardwarebeschleunigte Bildschirmaufnahme mit anpassbaren Video- und Audioeinstellungen",
+ "messages": {
+ "failed-general": "Der Recorder wurde mit einem Fehler beendet.",
+ "failed-gpu": "gpu-screen-recorder wurde unerwartet beendet.",
+ "failed-start": "Aufnahme konnte nicht gestartet werden.",
+ "no-portals": "Desktop-Portale laufen nicht.",
+ "no-portals-desc": "Starte xdg-desktop-portal und ein Compositor-Portal (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder ist nicht installiert.",
+ "not-installed-desc": "Bitte installieren Sie gpu-screen-recorder, um die Bildschirmaufzeichnungsfunktionen zu nutzen.",
+ "saved": "Aufnahme gespeichert.",
+ "start-recording": "Bildschirmaufnahme (Aufnahme starten)",
+ "started": "Aufnahme gestartet.",
+ "stop-recording": "Bildschirmaufnahme (Aufnahme stoppen)",
+ "stopping": "Aufnahme stoppen…",
+ "open-file": "Datei öffnen",
+ "monitor-not-found": "Could not detect the monitor under cursor.",
+ "replay-saved": "Replay gespeichert.",
+ "replay-started": "Replay-Puffer gestartet.",
+ "replay-stopped": "Replay-Puffer gestoppt.",
+ "replay-failed": "Fehler beim Starten des Replay-Puffers.",
+ "replay-active": "Bildschirmaufnahme (Replay aktiv)",
+ "save-replay": "Replay-Puffer speichern",
+ "start-replay": "Replay-Puffer starten",
+ "stop-replay": "Replay-Puffer stoppen"
+ },
+ "name": "Bildschirmrekorder",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Systemausgabe + Mikrofoneingang",
+ "audio-sources-microphone-input": "Mikrofoneingang",
+ "audio-sources-none": "Kein Audio",
+ "audio-sources-system-output": "Systemausgabe",
+ "codec": "Audio-Codec",
+ "codec-desc": "Opus wird für beste Leistung und kleinste Audiogröße empfohlen",
+ "source": "Audioquelle",
+ "source-desc": "Audioquelle, die während der Aufnahme aufgenommen werden soll",
+ "title": "Audioeinstellungen"
+ },
+ "filename-pattern": {
+ "description": "Dateimuster mit Datums-/Zeitcodes (z. B. aufnahme_yyyyMMdd_HHmmss)",
+ "label": "Dateimuster"
+ },
+ "general": {
+ "copy-to-clipboard": "In die Zwischenablage kopieren",
+ "copy-to-clipboard-description": "Kopiere die Datei nach Abschluss der Aufnahme in die Zwischenablage.",
+ "hide-when-inactive": "Bei Inaktivität ausblenden",
+ "hide-when-inactive-description": "Widget in Statusleiste ausblenden, wenn keine Aufnahme stattfindet",
+ "output-folder": "Ausgabeordner",
+ "output-folder-description": "Ordner, in dem Bildschirmaufnahmen gespeichert werden",
+ "restore-portal-session": "Portal-Sitzung wiederherstellen",
+ "restore-portal-session-description": "Stellt die vorherige Portal-Sitzung für nachfolgende Aufnahmen automatisch wieder her (deaktiviert die Auswahlaufforderung)",
+ "show-cursor": "Cursor anzeigen",
+ "show-cursor-description": "Mauszeiger im Video aufnehmen",
+ "title": "Allgemein"
+ },
+ "replay": {
+ "enable": "Replay-Puffer aktivieren",
+ "enable-desc": "Ein rollender Puffer der letzten Bildschirmaktivität beibehalten, der bei Bedarf gespeichert werden kann",
+ "duration": "Replay-Dauer",
+ "duration-desc": "Länge des Replay-Puffers in Sekunden",
+ "custom-duration": "Benutzerdefinierte Replay-Dauer",
+ "custom-duration-desc": "Geben Sie eine benutzerdefinierte Replay-Puffer-Dauer in Sekunden ein",
+ "storage": "Replay-Speicher",
+ "storage-desc": "Wo der Replay-Puffer gespeichert werden soll",
+ "storage-ram": "RAM (empfohlen)",
+ "storage-disk": "Festplatte"
+ },
+ "title": "Bildschirmrekorder-Einstellungen",
+ "video": {
+ "codec": "Video-Codec",
+ "codec-desc": "h264 ist der gängigste Codec",
+ "color-range": "Farbbereich",
+ "color-range-desc": "Begrenzt wird für bessere Kompatibilität empfohlen",
+ "color-range-full": "Vollständig",
+ "color-range-limited": "Begrenzt",
+ "frame-rate": "Bildrate",
+ "frame-rate-custom": "Benutzerdefiniert",
+ "frame-rate-desc": "Ziel-Bildrate für Bildschirmaufnahmen",
+ "custom-frame-rate": "Benutzerdefinierte Bildrate",
+ "custom-frame-rate-desc": "Geben Sie einen benutzerdefinierten Bildratenwert ein (FPS)",
+ "quality": "Videoqualität",
+ "quality-desc": "Höhere Qualität führt zu größeren Dateien",
+ "quality-high": "Hoch",
+ "quality-medium": "Mittel",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Sehr hoch",
+ "resolution": "Auflösung",
+ "resolution-desc": "Ausgabeauflösung begrenzen. Standard verwendet Originalbildschirmauflösung",
+ "resolution-original": "Standard",
+ "source": "Videoquelle",
+ "source-desc": "Portal wird empfohlen, bei Artefakten versuchen Sie Bildschirm",
+ "sources-portal": "Portal",
+ "sources-screen": "Bildschirm",
+ "title": "Videoeinstellungen",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/en.json b/.config/noctalia/plugins/screen-recorder/i18n/en.json
new file mode 100644
index 0000000..d5770a0
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/en.json
@@ -0,0 +1,99 @@
+{
+ "description": "Hardware-accelerated screen recording with customizable video and audio settings",
+ "messages": {
+ "failed-general": "Recorder exited with an error.",
+ "failed-gpu": "gpu-screen-recorder exited unexpectedly.",
+ "failed-start": "Failed to start recording.",
+ "no-portals": "Desktop portals not running.",
+ "no-portals-desc": "Start xdg-desktop-portal and a compositor portal (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder not installed.",
+ "not-installed-desc": "Please install gpu-screen-recorder to use screen recording features.",
+ "saved": "Recording saved.",
+ "start-recording": "Screen Recorder (start recording)",
+ "started": "Recording started.",
+ "stop-recording": "Screen Recorder (stop recording)",
+ "stopping": "Stopping recording…",
+ "open-file": "Open file",
+ "monitor-not-found": "Could not detect the monitor under cursor.",
+ "replay-saved": "Replay saved.",
+ "replay-started": "Replay buffer started.",
+ "replay-stopped": "Replay buffer stopped.",
+ "replay-failed": "Failed to start replay buffer.",
+ "replay-active": "Screen Recorder (replay active)",
+ "save-replay": "Save replay buffer",
+ "start-replay": "Start replay buffer",
+ "stop-replay": "Stop replay buffer"
+ },
+ "name": "Screen Recorder",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "System output + microphone input",
+ "audio-sources-microphone-input": "Microphone input",
+ "audio-sources-none": "No audio",
+ "audio-sources-system-output": "System output",
+ "codec": "Audio codec",
+ "codec-desc": "Opus is recommended for best performance and smallest audio size",
+ "source": "Audio source",
+ "source-desc": "Audio source to capture during recording",
+ "title": "Audio Settings"
+ },
+ "filename-pattern": {
+ "description": "Filename pattern using date/time codes (e.g., recording_yyyyMMdd_HHmmss)",
+ "label": "Filename pattern"
+ },
+ "general": {
+ "copy-to-clipboard": "Copy to clipboard",
+ "copy-to-clipboard-description": "Copy the file to clipboard after recording finishes.",
+ "hide-when-inactive": "Hide when inactive",
+ "hide-when-inactive-description": "Hide the bar indicator when not recording",
+ "output-folder": "Output folder",
+ "output-folder-description": "Folder where screen recordings will be saved",
+ "restore-portal-session": "Restore portal session",
+ "restore-portal-session-description": "Automatically restore the previous portal session for subsequent recordings (disables the selection prompt)",
+ "show-cursor": "Show cursor",
+ "show-cursor-description": "Record mouse cursor in the video",
+ "title": "General"
+ },
+ "replay": {
+ "enable": "Enable replay buffer",
+ "enable-desc": "Keep a rolling buffer of recent screen activity that can be saved on demand",
+ "duration": "Replay duration",
+ "duration-desc": "Length of the replay buffer in seconds",
+ "custom-duration": "Custom replay duration",
+ "custom-duration-desc": "Enter a custom replay buffer duration in seconds",
+ "storage": "Replay storage",
+ "storage-desc": "Where to store the replay buffer",
+ "storage-ram": "RAM (recommended)",
+ "storage-disk": "Disk"
+ },
+ "title": "Screen Recorder Settings",
+ "video": {
+ "codec": "Video codec",
+ "codec-desc": "h264 is the most common codec",
+ "color-range": "Color range",
+ "color-range-desc": "Limited is recommended for better compatibility",
+ "color-range-full": "Full",
+ "color-range-limited": "Limited",
+ "frame-rate": "Frame rate",
+ "frame-rate-custom": "Custom",
+ "frame-rate-desc": "Target frame rate for screen recordings",
+ "custom-frame-rate": "Custom frame rate",
+ "custom-frame-rate-desc": "Enter a custom frame rate value (FPS)",
+ "quality": "Video quality",
+ "quality-desc": "Higher quality results in larger file sizes",
+ "quality-high": "High",
+ "quality-medium": "Medium",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Very high",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Video source",
+ "source-desc": "Portal is recommended, if you get artifacts try Screen",
+ "sources-portal": "Portal",
+ "sources-screen": "Screen",
+ "sources-focused-monitor": "Focused monitor (Hyprland)",
+ "title": "Video Settings"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/es.json b/.config/noctalia/plugins/screen-recorder/i18n/es.json
new file mode 100644
index 0000000..542cdb8
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/es.json
@@ -0,0 +1,96 @@
+{
+ "description": "Grabación de pantalla acelerada por hardware con configuración personalizable de video y audio",
+ "messages": {
+ "failed-general": "El grabador salió con un error.",
+ "failed-gpu": "gpu-screen-recorder se cerró inesperadamente.",
+ "failed-start": "No se pudo iniciar la grabación.",
+ "no-portals": "Portales de escritorio no están en ejecución.",
+ "no-portals-desc": "Inicia xdg-desktop-portal y un portal de compositor (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder no está instalado.",
+ "not-installed-desc": "Por favor, instale gpu-screen-recorder para usar las funciones de grabación de pantalla.",
+ "saved": "Grabación guardada.",
+ "start-recording": "Grabador de pantalla (empezar a grabar)",
+ "started": "Grabación iniciada.",
+ "stop-recording": "Grabador de pantalla (detener grabación)",
+ "stopping": "Deteniendo la grabación…",
+ "monitor-not-found": "No se pudo detectar el monitor bajo el cursor.",
+ "replay-saved": "Replay guardado.",
+ "replay-started": "Búfer de replay iniciado.",
+ "replay-stopped": "Búfer de replay detenido.",
+ "replay-failed": "Error al iniciar el búfer de replay.",
+ "replay-active": "Grabador de pantalla (replay activo)",
+ "save-replay": "Guardar búfer de replay",
+ "start-replay": "Iniciar búfer de replay",
+ "stop-replay": "Detener búfer de replay"
+ },
+ "name": "Grabadora de Pantalla",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Salida del sistema + entrada de micrófono",
+ "audio-sources-microphone-input": "Entrada de micrófono",
+ "audio-sources-none": "Sin audio",
+ "audio-sources-system-output": "Salida del sistema",
+ "codec": "Códec de audio",
+ "codec-desc": "Opus se recomienda para mejor rendimiento y tamaño de audio más pequeño",
+ "source": "Fuente de audio",
+ "source-desc": "Fuente de audio para capturar durante la grabación",
+ "title": "Configuración de Audio"
+ },
+ "filename-pattern": {
+ "description": "Patrón de nombre de archivo usando códigos de fecha/hora (p. ej., recording_yyyyMMdd_HHmmss)",
+ "label": "Patrón de nombre de archivo"
+ },
+ "general": {
+ "copy-to-clipboard": "Copiar al portapapeles",
+ "copy-to-clipboard-description": "Copia el archivo al portapapeles después de que termine la grabación.",
+ "output-folder": "Carpeta de salida",
+ "output-folder-description": "Carpeta donde se guardarán las grabaciones de pantalla",
+ "restore-portal-session": "Restaurar sesión del portal",
+ "restore-portal-session-description": "Restaurar automáticamente la sesión anterior del portal para grabaciones posteriores (desactiva el aviso de selección)",
+ "show-cursor": "Mostrar cursor",
+ "show-cursor-description": "Grabar el cursor del ratón en el video",
+ "title": "General"
+ },
+ "replay": {
+ "enable": "Activar búfer de replay",
+ "enable-desc": "Mantener un búfer rodante de la actividad reciente de la pantalla que se puede guardar a pedido",
+ "duration": "Duración del replay",
+ "duration-desc": "Longitud del búfer de replay en segundos",
+ "custom-duration": "Duración de replay personalizada",
+ "custom-duration-desc": "Ingrese una duración de búfer de replay personalizada en segundos",
+ "storage": "Almacenamiento de replay",
+ "storage-desc": "Dónde almacenar el búfer de replay",
+ "storage-ram": "RAM (recomendado)",
+ "storage-disk": "Disco"
+ },
+ "title": "Configuración de Grabadora de Pantalla",
+ "video": {
+ "codec": "Códec de video",
+ "codec-desc": "h264 es el códec más común",
+ "color-range": "Rango de color",
+ "color-range-desc": "Limitado se recomienda para mejor compatibilidad",
+ "color-range-full": "Completo",
+ "color-range-limited": "Limitado",
+ "frame-rate": "Tasa de fotogramas",
+ "frame-rate-custom": "Personalizado",
+ "frame-rate-desc": "Tasa de fotogramas objetivo para grabaciones de pantalla",
+ "custom-frame-rate": "Tasa de fotogramas personalizada",
+ "custom-frame-rate-desc": "Ingrese un valor de tasa de fotogramas personalizado (FPS)",
+ "quality": "Calidad de video",
+ "quality-desc": "Mayor calidad resulta en archivos más grandes",
+ "quality-high": "Alto",
+ "quality-medium": "Mediano",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Muy alto",
+ "resolution": "Resolución",
+ "resolution-desc": "Limitar resolución de salida. Predeterminado usa resolución de pantalla original",
+ "resolution-original": "Default",
+ "source": "Fuente de video",
+ "source-desc": "Se recomienda Portal, si obtiene artefactos intente Pantalla",
+ "sources-portal": "Portal",
+ "sources-screen": "Pantalla",
+ "title": "Configuración de Video",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/fr.json b/.config/noctalia/plugins/screen-recorder/i18n/fr.json
new file mode 100644
index 0000000..9d7277b
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/fr.json
@@ -0,0 +1,99 @@
+{
+ "description": "Enregistrement d'écran accéléré par matériel avec paramètres vidéo et audio personnalisables",
+ "messages": {
+ "failed-general": "L'enregistreur a quitté avec une erreur.",
+ "failed-gpu": "L'enregistreur d'écran GPU s'est arrêté de manière inattendue.",
+ "failed-start": "Échec du démarrage de l'enregistrement.",
+ "no-portals": "Les portails de bureau ne fonctionnent pas.",
+ "no-portals-desc": "Démarrer xdg-desktop-portal et un portail de compositeur (wlr/hyprland/gnome/kde).",
+ "not-installed": "enregistreur d'écran gpu non installé.",
+ "not-installed-desc": "Veuillez installer gpu-screen-recorder pour utiliser les fonctionnalités d'enregistrement d'écran.",
+ "saved": "Enregistrement sauvegardé.",
+ "start-recording": "Enregistreur d'écran (démarrer l'enregistrement)",
+ "started": "Enregistrement démarré.",
+ "stop-recording": "Enregistreur d'écran (arrêter l'enregistrement)",
+ "stopping": "Arrêt de l'enregistrement...",
+ "open-file": "Ouvrir le fichier",
+ "monitor-not-found": "Impossible de détecter l'écran sous le curseur.",
+ "replay-saved": "Replay enregistré.",
+ "replay-started": "Tampon de replay démarré.",
+ "replay-stopped": "Tampon de replay arrêté.",
+ "replay-failed": "Échec du démarrage du tampon de replay.",
+ "replay-active": "Enregistreur d'écran (replay actif)",
+ "save-replay": "Enregistrer le tampon de replay",
+ "start-replay": "Démarrer le tampon de replay",
+ "stop-replay": "Arrêter le tampon de replay"
+ },
+ "name": "Enregistreur d'Écran",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Sortie système + entrée microphone",
+ "audio-sources-microphone-input": "Entrée microphone",
+ "audio-sources-none": "Pas d'audio",
+ "audio-sources-system-output": "Sortie système",
+ "codec": "Codec audio",
+ "codec-desc": "Opus est recommandé pour les meilleures performances et la plus petite taille audio",
+ "source": "Source audio",
+ "source-desc": "Source audio à capturer pendant l'enregistrement",
+ "title": "Paramètres Audio"
+ },
+ "filename-pattern": {
+ "description": "Modèle de nom de fichier utilisant les codes date/heure (par exemple, recording_yyyyMMdd_HHmmss)",
+ "label": "Modèle de nom de fichier"
+ },
+ "general": {
+ "copy-to-clipboard": "Copier dans le presse-papiers",
+ "copy-to-clipboard-description": "Copier le fichier dans le presse-papiers une fois l'enregistrement terminé.",
+ "hide-when-inactive": "Masquer lorsqu'inactif",
+ "hide-when-inactive-description": "Masquer l'indicateur de la barre lorsqu'il n'y a pas d'enregistrement",
+ "output-folder": "Dossier de sortie",
+ "output-folder-description": "Dossier où les enregistrements d'écran seront enregistrés",
+ "restore-portal-session": "Restaurer la session du portail",
+ "restore-portal-session-description": "Restaurer automatiquement la session du portail précédente pour les enregistrements ultérieurs (désactive l'invite de sélection)",
+ "show-cursor": "Afficher le curseur",
+ "show-cursor-description": "Enregistrer le curseur de la souris dans la vidéo",
+ "title": "Général"
+ },
+ "replay": {
+ "enable": "Activer le tampon de replay",
+ "enable-desc": "Conserver un tampon roulant de l'activité récente de l'écran qui peut être enregistré à la demande",
+ "duration": "Durée du replay",
+ "duration-desc": "Longueur du tampon de replay en secondes",
+ "custom-duration": "Durée de replay personnalisée",
+ "custom-duration-desc": "Saisissez une durée de tampon de replay personnalisée en secondes",
+ "storage": "Stockage du replay",
+ "storage-desc": "Où stocker le tampon de replay",
+ "storage-ram": "RAM (recommandé)",
+ "storage-disk": "Disque"
+ },
+ "title": "Paramètres de l'Enregistreur d'Écran",
+ "video": {
+ "codec": "Codec vidéo",
+ "codec-desc": "h264 est le codec le plus courant",
+ "color-range": "Gamme de couleurs",
+ "color-range-desc": "Limité est recommandé pour une meilleure compatibilité",
+ "color-range-full": "Complet / Plein",
+ "color-range-limited": "Limité(e)",
+ "frame-rate": "Fréquence d'images",
+ "frame-rate-custom": "Personnalisé",
+ "frame-rate-desc": "Fréquence d'images cible pour les enregistrements d'écran",
+ "custom-frame-rate": "Fréquence d'images personnalisée",
+ "custom-frame-rate-desc": "Entrez une valeur de fréquence d'images personnalisée (FPS)",
+ "quality": "Qualité vidéo",
+ "quality-desc": "Une qualité supérieure résulte en des fichiers plus volumineux",
+ "quality-high": "Haut/Haute",
+ "quality-medium": "Moyen",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Très élevé",
+ "resolution": "Résolution",
+ "resolution-desc": "Limiter la résolution de sortie. Par défaut utilise la résolution d'écran originale",
+ "resolution-original": "Default",
+ "source": "Source vidéo",
+ "source-desc": "Portal est recommandé, si vous obtenez des artefacts essayez Écran",
+ "sources-portal": "Portail",
+ "sources-screen": "Écran",
+ "sources-focused-monitor": "Écran sous le curseur (Hyprland)",
+ "title": "Paramètres Vidéo"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/hu.json b/.config/noctalia/plugins/screen-recorder/i18n/hu.json
new file mode 100644
index 0000000..d93bc2f
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/hu.json
@@ -0,0 +1,96 @@
+{
+ "description": "Hardveresen gyorsÃtott képernyÅ‘felvétel testreszabható videó- és hangbeállÃtásokkal",
+ "messages": {
+ "failed-general": "A rögzÃtÅ‘ hibával kilépett.",
+ "failed-gpu": "A gpu-screen-recorder váratlanul leállt.",
+ "failed-start": "A felvétel indÃtása sikertelen.",
+ "no-portals": "Az asztali portálok nem futnak.",
+ "no-portals-desc": "xdg-desktop-portal és egy kompozitor portál (wlr/hyprland/gnome/kde) indÃtása.",
+ "not-installed": "A gpu-screen-recorder nincs telepÃtve.",
+ "not-installed-desc": "Kérlek, telepÃtsd a gpu-screen-recorder alkalmazást a képernyÅ‘felvétel funkciók használatához.",
+ "saved": "A felvétel mentve.",
+ "start-recording": "KépernyÅ‘rögzÃtÅ‘ (felvétel indÃtása)",
+ "started": "A felvétel elindult.",
+ "stop-recording": "KépernyÅ‘felvevÅ‘ (felvétel leállÃtása)",
+ "stopping": "A felvétel leállÃtása…",
+ "monitor-not-found": "Nem sikerült felismerni a kurzor alatti monitort.",
+ "replay-saved": "Replay elmentve.",
+ "replay-started": "Replay puffer elindÃtva.",
+ "replay-stopped": "Replay puffer leállÃtva.",
+ "replay-failed": "Nem sikerült elindÃtani a replay puffert.",
+ "replay-active": "KépernyÅ‘rögzÃtÅ‘ (replay aktÃv)",
+ "save-replay": "Replay puffer mentése",
+ "start-replay": "Replay puffer indÃtása",
+ "stop-replay": "Replay puffer leállÃtása"
+ },
+ "name": "Képernyőfelvevő",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Rendszerkimenet + mikrofonbemenet",
+ "audio-sources-microphone-input": "Mikrofon bemenet",
+ "audio-sources-none": "Nincs hang",
+ "audio-sources-system-output": "Rendszerkimenet",
+ "codec": "Audió kodek",
+ "codec-desc": "Az Opus ajánlott a legjobb teljesÃtmény és a legkisebb hangméret érdekében",
+ "source": "Hangforrás",
+ "source-desc": "HangrögzÃtés során rögzÃtendÅ‘ hangforrás",
+ "title": "HangbeállÃtások"
+ },
+ "filename-pattern": {
+ "description": "Fájlnév minta dátum/idő kódokkal (pl. recording_yyyyMMdd_HHmmss)",
+ "label": "Fájlnév minta"
+ },
+ "general": {
+ "copy-to-clipboard": "Másolás a vágólapra",
+ "copy-to-clipboard-description": "A felvétel befejezése után másolja a fájlt a vágólapra.",
+ "output-folder": "Kimeneti mappa",
+ "output-folder-description": "A mappa, ahová a képernyőfelvételek mentésre kerülnek",
+ "restore-portal-session": "Portál munkamenet visszaállÃtása",
+ "restore-portal-session-description": "Az elÅ‘zÅ‘ portál munkamenet automatikus visszaállÃtása a következÅ‘ felvételekhez (kikapcsolja a választóablakot)",
+ "show-cursor": "Kurzor megjelenÃtése",
+ "show-cursor-description": "Egérkurzor rögzÃtése a videóban",
+ "title": "Ãltalános"
+ },
+ "replay": {
+ "enable": "Replay puffer engedélyezése",
+ "enable-desc": "Tartson egy gördülő puffert a legutóbbi képernyőaktivitásról, amely igény szerint elmenthető",
+ "duration": "Replay időtartama",
+ "duration-desc": "A replay puffer hossza másodpercben",
+ "custom-duration": "Egyéni replay időtartam",
+ "custom-duration-desc": "Adjon meg egy egyéni replay puffer időtartamot másodpercben",
+ "storage": "Replay tároló",
+ "storage-desc": "Hol tárolja a replay puffert",
+ "storage-ram": "RAM (ajánlott)",
+ "storage-disk": "Lemez"
+ },
+ "title": "KépernyÅ‘rögzÃtÅ‘ beállÃtások",
+ "video": {
+ "codec": "Videó kodek",
+ "codec-desc": "a h264 a legelterjedtebb kodek",
+ "color-range": "SzÃntartomány",
+ "color-range-desc": "A korlátozott ajánlott a jobb kompatibilitás érdekében",
+ "color-range-full": "Teljes",
+ "color-range-limited": "Korlátozott",
+ "frame-rate": "Képkockasebesség",
+ "frame-rate-custom": "Egyéni",
+ "frame-rate-desc": "Képernyőfelvételek célzott képkockasebessége",
+ "custom-frame-rate": "Egyéni képkockasebesség",
+ "custom-frame-rate-desc": "Adjon meg egyéni képkockasebesség értéket (FPS)",
+ "quality": "Videóminőség",
+ "quality-desc": "A jobb minőség nagyobb fájlméretet eredményez",
+ "quality-high": "Magas",
+ "quality-medium": "Közepes",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Nagyon magas",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Videóforrás",
+ "source-desc": "A Portal ajánlott, ha hibákat tapasztalsz, próbáld a Screen-t",
+ "sources-portal": "Portál",
+ "sources-screen": "Képernyő",
+ "title": "VideóbeállÃtások",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/it.json b/.config/noctalia/plugins/screen-recorder/i18n/it.json
new file mode 100644
index 0000000..b983a82
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/it.json
@@ -0,0 +1,96 @@
+{
+ "description": "Registrazione dello schermo con accelerazione hardware e impostazioni video e audio personalizzabili",
+ "messages": {
+ "failed-general": "Registratore uscito con un errore.",
+ "failed-gpu": "ضبط الشاشة باستخدام معالج الرسوميات توق٠بشكل غير متوقع.",
+ "failed-start": "Nuk arrita të filloj regjistrimin.",
+ "no-portals": "Non è in esecuzione alcun portale desktop.",
+ "no-portals-desc": "Avvia xdg-desktop-portal e un portale compositore (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder non è installato.",
+ "not-installed-desc": "Bitte installiere gpu-screen-recorder, um die Bildschirmaufzeichnungsfunktionen zu nutzen.",
+ "saved": "Registrazione salvata.",
+ "start-recording": "P錄影 (開始錄影)",
+ "started": "Aufnahme gestartet.",
+ "stop-recording": "Skjáupptökutæki (hætta upptöku)",
+ "stopping": "Nagpapatigil ng pagrekord...",
+ "monitor-not-found": "Impossibile rilevare il monitor sotto il cursore.",
+ "replay-saved": "Replay salvato.",
+ "replay-started": "Buffer di replay avviato.",
+ "replay-stopped": "Buffer di replay interrotto.",
+ "replay-failed": "Impossibile avviare il buffer di replay.",
+ "replay-active": "Registratore schermo (replay attivo)",
+ "save-replay": "Salva buffer di replay",
+ "start-replay": "Avvia buffer di replay",
+ "stop-replay": "Interrompi buffer di replay"
+ },
+ "name": "Registratore Schermo",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Risultato del sistema + ingresso microfono",
+ "audio-sources-microphone-input": "Eingang für Mikrofon",
+ "audio-sources-none": "Léir aon fhuaim",
+ "audio-sources-system-output": "Risultato del sistema",
+ "codec": "Codec audio",
+ "codec-desc": "Opus è consigliato per le migliori prestazioni e dimensioni audio più piccole",
+ "source": "Sorgente audio",
+ "source-desc": "Sorgente audio da catturare durante la registrazione",
+ "title": "Impostazioni Audio"
+ },
+ "filename-pattern": {
+ "description": "Padrone di u nome di u schedariu chì usa i codici di data/ora (per esempiu, recording_yyyyMMdd_HHmmss)",
+ "label": "Modello nome file"
+ },
+ "general": {
+ "copy-to-clipboard": "Copia negli appunti",
+ "copy-to-clipboard-description": "Kopyahin ang file sa clipboard pagkatapos matapos ang pag-record.",
+ "output-folder": "Cartella di output",
+ "output-folder-description": "Cartella dove verranno salvate le registrazioni dello schermo",
+ "restore-portal-session": "Restore portal session",
+ "restore-portal-session-description": "Automatically restore the previous portal session for subsequent recordings (disables the selection prompt)",
+ "show-cursor": "Mostra cursore",
+ "show-cursor-description": "Registra il cursore del mouse nel video",
+ "title": "Generale"
+ },
+ "replay": {
+ "enable": "Abilita buffer di replay",
+ "enable-desc": "Mantiene un buffer ciclico dell'attività recente dello schermo che può essere salvato a richiesta",
+ "duration": "Durata replay",
+ "duration-desc": "Lunghezza del buffer di replay in secondi",
+ "custom-duration": "Durata replay personalizzata",
+ "custom-duration-desc": "Inserisci una durata personalizzata del buffer di replay in secondi",
+ "storage": "Archiviazione replay",
+ "storage-desc": "Dove archiviare il buffer di replay",
+ "storage-ram": "RAM (consigliato)",
+ "storage-disk": "Disco"
+ },
+ "title": "Impostazioni Registratore Schermo",
+ "video": {
+ "codec": "Codec video",
+ "codec-desc": "h264 è il codec più comune",
+ "color-range": "Gamma colori",
+ "color-range-desc": "Limitato è consigliato per una migliore compatibilità ",
+ "color-range-full": "Puno",
+ "color-range-limited": "Limitato",
+ "frame-rate": "Frequenza fotogrammi",
+ "frame-rate-custom": "Personalizzato",
+ "frame-rate-desc": "Frequenza fotogrammi target per le registrazioni dello schermo",
+ "custom-frame-rate": "Frequenza fotogrammi personalizzata",
+ "custom-frame-rate-desc": "Inserisci un valore personalizzato di frequenza fotogrammi (FPS)",
+ "quality": "Qualità video",
+ "quality-desc": "Qualità superiore risulta in file più grandi",
+ "quality-high": "Alta",
+ "quality-medium": "Mezzano",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Molto alto",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Sorgente video",
+ "source-desc": "Portal è consigliato, se si ottengono artefatti prova Schermo",
+ "sources-portal": "Portale",
+ "sources-screen": "Skjerm",
+ "title": "Impostazioni Video",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/ja.json b/.config/noctalia/plugins/screen-recorder/i18n/ja.json
new file mode 100644
index 0000000..376bb8d
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/ja.json
@@ -0,0 +1,99 @@
+{
+ "description": "カスタマイズå¯èƒ½ãªãƒ“デオãŠã‚ˆã³ã‚ªãƒ¼ãƒ‡ã‚£ã‚ªè¨å®šã‚’å‚™ãˆãŸãƒãƒ¼ãƒ‰ã‚¦ã‚§ã‚¢ã‚¢ã‚¯ã‚»ãƒ©ãƒ¬ãƒ¼ã‚·ãƒ§ãƒ³å¯¾å¿œã®ç”»é¢éŒ²ç”»",
+ "messages": {
+ "failed-general": "レコーダーãŒã‚¨ãƒ©ãƒ¼ã§çµ‚了ã—ã¾ã—ãŸã€‚",
+ "failed-gpu": "gpu-screen-recorder ãŒäºˆæœŸã›ãšçµ‚了ã—ã¾ã—ãŸã€‚",
+ "failed-start": "録画を開始ã§ãã¾ã›ã‚“ã§ã—ãŸã€‚",
+ "no-portals": "デスクトップãƒãƒ¼ã‚¿ãƒ«ãŒå®Ÿè¡Œã•れã¦ã„ã¾ã›ã‚“。",
+ "no-portals-desc": "xdg-desktop-portalã¨ã‚³ãƒ³ãƒã‚¸ã‚¿ãƒ¼ãƒãƒ¼ã‚¿ãƒ« (wlr/hyprland/gnome/kde) ã‚’èµ·å‹•ã—ã¾ã™ã€‚",
+ "not-installed": "gpu-screen-recorder ãŒã‚¤ãƒ³ã‚¹ãƒˆãƒ¼ãƒ«ã•れã¦ã„ã¾ã›ã‚“。",
+ "not-installed-desc": "ç”»é¢éŒ²ç”»æ©Ÿèƒ½ã‚’使用ã™ã‚‹ã«ã¯ã€gpu-screen-recorderをインストールã—ã¦ãã ã•ã„。",
+ "saved": "録画をä¿å˜ã—ã¾ã—ãŸã€‚",
+ "start-recording": "ç”»é¢éŒ²ç”»ï¼ˆé–‹å§‹ï¼‰",
+ "started": "録画を開始ã—ã¾ã—ãŸã€‚",
+ "stop-recording": "ç”»é¢éŒ²ç”»ï¼ˆåœæ¢ï¼‰",
+ "stopping": "éŒ²ç”»ã‚’åœæ¢ã—ã¦ã„ã¾ã™â€¦",
+ "open-file": "ファイルを開ã",
+ "monitor-not-found": "カーソル下ã®ãƒ¢ãƒ‹ã‚¿ãƒ¼ã‚’検出ã§ãã¾ã›ã‚“ã§ã—ãŸã€‚",
+ "replay-saved": "リプレイãŒä¿å˜ã•れã¾ã—ãŸã€‚",
+ "replay-started": "リプレイãƒãƒƒãƒ•ã‚¡ãŒé–‹å§‹ã•れã¾ã—ãŸã€‚",
+ "replay-stopped": "リプレイãƒãƒƒãƒ•ã‚¡ãŒåœæ¢ã•れã¾ã—ãŸã€‚",
+ "replay-failed": "リプレイãƒãƒƒãƒ•ã‚¡ã®é–‹å§‹ã«å¤±æ•—ã—ã¾ã—ãŸã€‚",
+ "replay-active": "スクリーンレコーダー (リプレイ有効)",
+ "save-replay": "リプレイãƒãƒƒãƒ•ã‚¡ã‚’ä¿å˜",
+ "start-replay": "リプレイãƒãƒƒãƒ•ã‚¡ã‚’é–‹å§‹",
+ "stop-replay": "リプレイãƒãƒƒãƒ•ã‚¡ã‚’åœæ¢"
+ },
+ "name": "ç”»é¢ãƒ¬ã‚³ãƒ¼ãƒ€ãƒ¼",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "システム出力 + マイク入力",
+ "audio-sources-microphone-input": "マイク入力",
+ "audio-sources-none": "音声ãªã—",
+ "audio-sources-system-output": "システム出力",
+ "codec": "オーディオコーデック",
+ "codec-desc": "最高ã®ãƒ‘ãƒ•ã‚©ãƒ¼ãƒžãƒ³ã‚¹ã¨æœ€å°ã®ã‚ªãƒ¼ãƒ‡ã‚£ã‚ªã‚µã‚¤ã‚ºã«ã¯OpusãŒæŽ¨å¥¨ã•れã¾ã™",
+ "source": "オーディオソース",
+ "source-desc": "録画ä¸ã«ã‚ャプãƒãƒ£ã•れるオーディオソース",
+ "title": "オーディオè¨å®š"
+ },
+ "filename-pattern": {
+ "description": "日付/時刻コードを使用ã—ãŸãƒ•ァイルåã®ãƒ‘ターン(例:recording_yyyyMMdd_HHmmss)",
+ "label": "ファイルåã®ãƒ‘ターン"
+ },
+ "general": {
+ "copy-to-clipboard": "クリップボードã«ã‚³ãƒ”ー",
+ "copy-to-clipboard-description": "録画終了後ã€ãƒ•ァイルをクリップボードã«ã‚³ãƒ”ーã™ã‚‹",
+ "hide-when-inactive": "éžã‚¢ã‚¯ãƒ†ã‚£ãƒ–時ã«éžè¡¨ç¤º",
+ "hide-when-inactive-description": "録画ã—ã¦ã„ãªã„ã¨ãã¯ã‚¦ã‚£ã‚¸ã‚§ãƒƒãƒˆã‚’éš ã™",
+ "output-folder": "出力フォルダー",
+ "output-folder-description": "ç”»é¢éŒ²ç”»ãŒä¿å˜ã•れるフォルダー",
+ "restore-portal-session": "ãƒãƒ¼ã‚¿ãƒ«ã‚»ãƒƒã‚·ãƒ§ãƒ³ã‚’復元ã™ã‚‹",
+ "restore-portal-session-description": "以é™ã®éŒ²ç”»ã®ãŸã‚ã«å‰ã®ãƒãƒ¼ã‚¿ãƒ«ã‚»ãƒƒã‚·ãƒ§ãƒ³ã‚’自動的ã«å¾©å…ƒã—ã¾ã™ï¼ˆé¸æŠžãƒ—ãƒãƒ³ãƒ—トを無効ã«ã—ã¾ã™ï¼‰",
+ "show-cursor": "カーソルを表示",
+ "show-cursor-description": "録画ã«ãƒžã‚¦ã‚¹ã‚«ãƒ¼ã‚½ãƒ«ã‚’記録ã™ã‚‹",
+ "title": "全般"
+ },
+ "replay": {
+ "enable": "リプレイãƒãƒƒãƒ•ァを有効ã«ã™ã‚‹",
+ "enable-desc": "最近ã®ç”»é¢ã‚¢ã‚¯ãƒ†ã‚£ãƒ“ティã®ãƒãƒ¼ãƒªãƒ³ã‚°ãƒãƒƒãƒ•ã‚¡ã‚’ä¿æŒã—ã€å¿…è¦ã«å¿œã˜ã¦ä¿å˜ã§ãるよã†ã«ã—ã¾ã™",
+ "duration": "リプレイ時間",
+ "duration-desc": "リプレイãƒãƒƒãƒ•ã‚¡ã®é•·ã•(秒)",
+ "custom-duration": "カスタムリプレイ時間",
+ "custom-duration-desc": "カスタムリプレイãƒãƒƒãƒ•ã‚¡ã®æ™‚é–“ã‚’ç§’å˜ä½ã§å…¥åŠ›ã—ã¦ãã ã•ã„",
+ "storage": "リプレイストレージ",
+ "storage-desc": "リプレイãƒãƒƒãƒ•ã‚¡ã®ä¿å˜å…ˆ",
+ "storage-ram": "RAM (推奨)",
+ "storage-disk": "ディスク"
+ },
+ "title": "ç”»é¢ãƒ¬ã‚³ãƒ¼ãƒ€ãƒ¼è¨å®š",
+ "video": {
+ "codec": "ビデオコーデック",
+ "codec-desc": "h264ã¯æœ€ã‚‚一般的ãªã‚³ãƒ¼ãƒ‡ãƒƒã‚¯ã§ã™",
+ "color-range": "色範囲",
+ "color-range-desc": "äº’æ›æ€§ã‚’高ã‚ã‚‹ãŸã‚ã€é™å®šãŒæŽ¨å¥¨ã•れã¾ã™",
+ "color-range-full": "フル",
+ "color-range-limited": "é™å®š",
+ "frame-rate": "フレームレート",
+ "frame-rate-custom": "カスタム",
+ "frame-rate-desc": "ç”»é¢éŒ²ç”»ã®ç›®æ¨™ãƒ•レームレート",
+ "custom-frame-rate": "カスタムフレームレート",
+ "custom-frame-rate-desc": "カスタムフレームレート値を入力ã—ã¦ãã ã•ã„(FPS)",
+ "quality": "ビデオå“質",
+ "quality-desc": "å“質ãŒé«˜ã„ã»ã©ã€ãƒ•ァイルサイズãŒå¤§ãããªã‚Šã¾ã™",
+ "quality-high": "高",
+ "quality-medium": "ä¸",
+ "quality-ultra": "最高",
+ "quality-very-high": "超高",
+ "resolution": "è§£åƒåº¦",
+ "resolution-desc": "出力解åƒåº¦ã‚’制é™ã—ã¾ã™ã€‚デフォルトã§ã¯å…ƒã®ç”»é¢è§£åƒåº¦ãŒä½¿ç”¨ã•れã¾ã™",
+ "resolution-original": "デフォルト",
+ "source": "ビデオソース",
+ "source-desc": "ãƒãƒ¼ã‚¿ãƒ«ãŒæŽ¨å¥¨ã•れã¾ã™ã€‚アーティファクトãŒç™ºç”Ÿã™ã‚‹å ´åˆã¯ã€Œã‚¹ã‚¯ãƒªãƒ¼ãƒ³ã€ã‚’試ã—ã¦ãã ã•ã„",
+ "sources-portal": "ãƒãƒ¼ã‚¿ãƒ«",
+ "sources-screen": "スクリーン",
+ "title": "ビデオè¨å®š",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/ku.json b/.config/noctalia/plugins/screen-recorder/i18n/ku.json
new file mode 100644
index 0000000..9e6adc4
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/ku.json
@@ -0,0 +1,96 @@
+{
+ "description": "Qeydkirina dîmenderê ya bi lezkerdana hardware bi mîhengên vîdyo û deng ên xwerû",
+ "messages": {
+ "failed-general": "Qeydker bi xeletî derket.",
+ "failed-gpu": "Qeydkerê dîmenderê GPU bi awayekî nediyar derket.",
+ "failed-start": "Dest pê kirina qeydê bi ser neket.",
+ "no-portals": "Portalên sermaseyê naxebitin.",
+ "no-portals-desc": "Xdg-desktop-portal û portalekî komposîtorê (wlr/hyprland/gnome/kde) bide destpêkirin.",
+ "not-installed": "gpu-screen-recorder nehati hati saz kirin.",
+ "not-installed-desc": "Ji bo bikaranîna taybetmendiyên tomarkirina dîmenderê, ji kerema xwe gpu-screen-recorder saz bikin.",
+ "saved": "Tomar qeyd kirin.",
+ "start-recording": "Qeydkerê ekranê (dest bi qeydkirinê bike)",
+ "started": "Qeydkirin dest pê kir.",
+ "stop-recording": "Tomarkera dîmenderê (tomarkirinê bide sekinandin)",
+ "stopping": "Qeydkirin radiweste…",
+ "monitor-not-found": "Monitora bin nîşanderê nehat dîtin.",
+ "replay-saved": "Vegerandin hat qeydkirin.",
+ "replay-started": "Depo vegerandinê dest pê kir.",
+ "replay-stopped": "Depo vegerandinê sekinî.",
+ "replay-failed": "Destpêkirina depo vegerandinê bi ser neket.",
+ "replay-active": "Qeydkerê dîmenderê (vegerandin çalak e)",
+ "save-replay": "Depo vegerandinê qeyd bike",
+ "start-replay": "Depo vegerandinê bide destpêkirin",
+ "stop-replay": "Depo vegerandinê bide sekinandin"
+ },
+ "name": "Qeydkerê Ekranê",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Derçûna pergalê + têketina mîkrofonê",
+ "audio-sources-microphone-input": "Ketina mîkrofonê",
+ "audio-sources-none": "Ti dengê tune",
+ "audio-sources-system-output": "Derçûna pergalê",
+ "codec": "Kodêkê deng",
+ "codec-desc": "Opus ji bo performansa herî baş û mezinahiya dengî ya herî biçûk tê pêşniyar kirin",
+ "source": "Çavkanîya deng",
+ "source-desc": "Çavkanîya dengî ya ku di dema qeydkirinê de were girtin",
+ "title": "Mîhengên Deng"
+ },
+ "filename-pattern": {
+ "description": "Şiklê navê pelê ku kodên dîrok/dem bikar tîne (mînak, recording_yyyyMMdd_HHmmss)",
+ "label": "Nimûneya navê pelê"
+ },
+ "general": {
+ "copy-to-clipboard": "Kopyayî ser clipboardê bike",
+ "copy-to-clipboard-description": "Piştî qedandina tomarkirinê pelê kopî klîpbordê bike.",
+ "output-folder": "Cilda peldanka ya derketinê",
+ "output-folder-description": "Peldanka ku qeydên ekranê lê werên rizgarkirin",
+ "restore-portal-session": "Vegerandina rûniştina portalê",
+ "restore-portal-session-description": "Ji bo qeydên paşê bixweber rûniştina portalê ya berê vedigerîne (pêşniyara hilbijartinê neçalak dike)",
+ "show-cursor": "Nîşanderê nîşan bide",
+ "show-cursor-description": "Qeydê nîşankerê mişkê di vîdeoyê de",
+ "title": "Giştî"
+ },
+ "replay": {
+ "enable": "Depo vegerandinê çalak bike",
+ "enable-desc": "Depoyek gerok a çalakiyên dîmenderê bihêle ku dikare li ser daxwazê were qeydkirin",
+ "duration": "Dama vegerandinê",
+ "duration-desc": "Dirêjahiya depo vegerandinê bi çirkeyan",
+ "custom-duration": "Dama vegerandinê ya xwerû",
+ "custom-duration-desc": "Dama depo vegerandinê ya xwerû bi çirkeyan binivîse",
+ "storage": "Depoya vegerandinê",
+ "storage-desc": "Depo vegerandinê li ku were hilanîn",
+ "storage-ram": "RAM (tê pêşniyarkirin)",
+ "storage-disk": "Dîsk"
+ },
+ "title": "Mîhengên Qeydkerê Dîmenderê",
+ "video": {
+ "codec": "Kodêka vîdyoyî",
+ "codec-desc": "h264 kodêka herî berbelav e",
+ "color-range": "Rêza rengan",
+ "color-range-desc": "Bi sînorkirî ji bo lihevkirina baştir tê pêşniyarkirin",
+ "color-range-full": "Tije",
+ "color-range-limited": "Sînorkirî",
+ "frame-rate": "Rêjeya çarçoveyê",
+ "frame-rate-custom": "Kesane",
+ "frame-rate-desc": "Rêjeya çarçoveya hedefê ji bo qeydên ekranê",
+ "custom-frame-rate": "Rêjeya çarçoveya kesane",
+ "custom-frame-rate-desc": "Nirxek rêjeya çarçoveya kesane binivîse (FPS)",
+ "quality": "Kalîteya vîdyoyê",
+ "quality-desc": "Encamên bi kalîte bilindtir dibin sedema mezinahiyên pelan ên mezintir",
+ "quality-high": "Bilind",
+ "quality-medium": "Navîn",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Zaf bilind",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Çavkanîya vîdyoyê",
+ "source-desc": "Portal tê pêşniyarkirin, eger tu artefaktan bibînî Screen biceribîne",
+ "sources-portal": "Portal",
+ "sources-screen": "Dîmende",
+ "title": "Mîhengên Vîdeoyê",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/nl.json b/.config/noctalia/plugins/screen-recorder/i18n/nl.json
new file mode 100644
index 0000000..cab9898
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/nl.json
@@ -0,0 +1,96 @@
+{
+ "description": "Hardware-versnelde schermopname met aanpasbare video- en audio-instellingen",
+ "messages": {
+ "failed-general": "Recorder is afgesloten met een fout.",
+ "failed-gpu": "gpu-screen-recorder is onverwachts afgesloten.",
+ "failed-start": "Opname starten mislukt.",
+ "no-portals": "Desktop portals werken niet.",
+ "no-portals-desc": "Start xdg-desktop-portal en een compositor portaal (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder niet geïnstalleerd.",
+ "not-installed-desc": "Installeer gpu-screen-recorder om schermopnamefuncties te gebruiken.",
+ "saved": "Opname opgeslagen.",
+ "start-recording": "Schermrecorder (opname starten)",
+ "started": "Opname gestart.",
+ "stop-recording": "Schermrecorder (opname stoppen)",
+ "stopping": "Opname stoppen…",
+ "monitor-not-found": "Kon de monitor onder de cursor niet detecteren.",
+ "replay-saved": "Replay opgeslagen.",
+ "replay-started": "Replay-buffer gestart.",
+ "replay-stopped": "Replay-buffer gestopt.",
+ "replay-failed": "Fout bij starten van replay-buffer.",
+ "replay-active": "Schermrecorder (replay actief)",
+ "save-replay": "Replay-buffer opslaan",
+ "start-replay": "Replay-buffer starten",
+ "stop-replay": "Replay-buffer stoppen"
+ },
+ "name": "Schermrecorder",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Systeemuitvoer + microfooningang",
+ "audio-sources-microphone-input": "Microfooningang",
+ "audio-sources-none": "Geen audio",
+ "audio-sources-system-output": "Systeemuitvoer",
+ "codec": "Audio-codec",
+ "codec-desc": "Opus wordt aanbevolen voor beste prestaties en kleinste audiogrootte",
+ "source": "Audiobron",
+ "source-desc": "Audiobron om tijdens opname vast te leggen",
+ "title": "Audio-instellingen"
+ },
+ "filename-pattern": {
+ "description": "Bestandsnaam patroon met datum/tijd codes (bijv. recording_yyyyMMdd_HHmmss)",
+ "label": "Bestandsnaam patroon"
+ },
+ "general": {
+ "copy-to-clipboard": "Kopiëren naar klembord",
+ "copy-to-clipboard-description": "Kopieer het bestand naar het klembord nadat de opname is voltooid.",
+ "output-folder": "Uitvoermap",
+ "output-folder-description": "Map waar schermopnames worden opgeslagen",
+ "restore-portal-session": "Portalsessie herstellen",
+ "restore-portal-session-description": "Herstel automatisch de vorige portalsessie voor volgende opnamen (schakelt het selectievenster uit)",
+ "show-cursor": "Cursor tonen",
+ "show-cursor-description": "Muiscursor in de video opnemen",
+ "title": "Algemeen"
+ },
+ "replay": {
+ "enable": "Replay-buffer inschakelen",
+ "enable-desc": "Een rollende buffer van recente schermactiviteit bijhouden die op verzoek kan worden opgeslagen",
+ "duration": "Replay-duur",
+ "duration-desc": "Lengte van de replay-buffer in seconden",
+ "custom-duration": "Aangepaste replay-duur",
+ "custom-duration-desc": "Voer een aangepaste replay-bufferduur in seconden in",
+ "storage": "Replay-opslag",
+ "storage-desc": "Waar de replay-buffer moet worden opgeslagen",
+ "storage-ram": "RAM (aanbevolen)",
+ "storage-disk": "Schijf"
+ },
+ "title": "Schermrecorder Instellingen",
+ "video": {
+ "codec": "Video-codec",
+ "codec-desc": "h264 is de meest voorkomende codec",
+ "color-range": "Kleurbereik",
+ "color-range-desc": "Beperkt wordt aanbevolen voor betere compatibiliteit",
+ "color-range-full": "Volledig",
+ "color-range-limited": "Beperkt",
+ "frame-rate": "Framesnelheid",
+ "frame-rate-custom": "Aangepast",
+ "frame-rate-desc": "Doelframesnelheid voor schermopnames",
+ "custom-frame-rate": "Aangepaste framesnelheid",
+ "custom-frame-rate-desc": "Voer een aangepaste framesnelheid in (FPS)",
+ "quality": "Videokwaliteit",
+ "quality-desc": "Hogere kwaliteit resulteert in grotere bestanden",
+ "quality-high": "Hoog",
+ "quality-medium": "Medium",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Zeer hoog",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Videobron",
+ "source-desc": "Portal wordt aanbevolen, probeer Scherm als u artefacten krijgt",
+ "sources-portal": "Portaal",
+ "sources-screen": "Scherm",
+ "title": "Video-instellingen",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/pl.json b/.config/noctalia/plugins/screen-recorder/i18n/pl.json
new file mode 100644
index 0000000..13d7d2a
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/pl.json
@@ -0,0 +1,96 @@
+{
+ "description": "Nagrywanie ekranu z akceleracją sprzętową z możliwością dostosowania ustawień wideo i audio",
+ "messages": {
+ "failed-general": "Rejestrator zakończył pracę z błędem.",
+ "failed-gpu": "Rejestrator ekranu GPU zakończył pracę nieoczekiwanie.",
+ "failed-start": "Nie udało się rozpocząć nagrywania.",
+ "no-portals": "Portale pulpitu nie działają.",
+ "no-portals-desc": "Uruchom xdg-desktop-portal i portal kompozytora (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder nie jest zainstalowany.",
+ "not-installed-desc": "Zainstaluj gpu-screen-recorder, aby korzystać z funkcji nagrywania ekranu.",
+ "saved": "Nagranie zapisane.",
+ "start-recording": "Rejestrator ekranu (rozpocznij nagrywanie)",
+ "started": "Rozpoczęto nagrywanie.",
+ "stop-recording": "Rejestrator ekranu (zatrzymaj nagrywanie)",
+ "stopping": "Zatrzymywanie nagrywania…",
+ "monitor-not-found": "Nie udało się wykryć monitora pod kursorem.",
+ "replay-saved": "Powtórka zapisana.",
+ "replay-started": "Bufor powtórki uruchomiony.",
+ "replay-stopped": "Bufor powtórki zatrzymany.",
+ "replay-failed": "Nie udało się uruchomić bufora powtórki.",
+ "replay-active": "Rejestrator ekranu (powtórka aktywna)",
+ "save-replay": "Zapisz bufor powtórki",
+ "start-replay": "Uruchom bufor powtórki",
+ "stop-replay": "Zatrzymaj bufor powtórki"
+ },
+ "name": "Rejestrator ekranu",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Wyjście systemowe + wejście mikrofonu",
+ "audio-sources-microphone-input": "Wejście mikrofonu",
+ "audio-sources-none": "Brak dźwięku",
+ "audio-sources-system-output": "Wyjście systemowe",
+ "codec": "Kodek audio",
+ "codec-desc": "Opus jest zalecany dla najlepszej wydajności i najmniejszego rozmiaru dźwięku",
+ "source": "Źródło dźwięku",
+ "source-desc": "Źródło dźwięku do przechwytywania podczas nagrywania",
+ "title": "Ustawienia audio"
+ },
+ "filename-pattern": {
+ "description": "Wzorzec nazwy pliku używający kodów daty/czasu (np. nagranie_rrrrMMdd_HHmmss)",
+ "label": "Wzorzec nazwy pliku"
+ },
+ "general": {
+ "copy-to-clipboard": "Kopiuj do schowka",
+ "copy-to-clipboard-description": "Kopiuj plik do schowka po zakończeniu nagrywania.",
+ "output-folder": "Folder wyjściowy",
+ "output-folder-description": "Folder, w którym będą zapisywane nagrania ekranu",
+ "restore-portal-session": "Przywróć sesję portalu",
+ "restore-portal-session-description": "Automatycznie przywracaj poprzednią sesję portalu dla kolejnych nagrań (wyłącza monit o wybór)",
+ "show-cursor": "Pokaż kursor",
+ "show-cursor-description": "Nagrywaj kursor myszy na filmie",
+ "title": "Ogólne"
+ },
+ "replay": {
+ "enable": "Włącz bufor powtórki",
+ "enable-desc": "Utrzymuj bufor cykliczny ostatniej aktywności ekranu, który można zapisać na żądanie",
+ "duration": "Czas trwania powtórki",
+ "duration-desc": "Długość bufora powtórki w sekundach",
+ "custom-duration": "Niestandardowy czas trwania powtórki",
+ "custom-duration-desc": "Wprowadź niestandardowy czas trwania bufora powtórki w sekundach",
+ "storage": "Przechowywanie powtórki",
+ "storage-desc": "Gdzie przechowywać bufor powtórki",
+ "storage-ram": "RAM (zalecane)",
+ "storage-disk": "Dysk"
+ },
+ "title": "Ustawienia rejestratora ekranu",
+ "video": {
+ "codec": "Kodek wideo",
+ "codec-desc": "h264 to najpopularniejszy kodek",
+ "color-range": "Zakres kolorów",
+ "color-range-desc": "Zalecane jest \"Ograniczone\" dla lepszej kompatybilności",
+ "color-range-full": "Pełny",
+ "color-range-limited": "Ograniczone",
+ "frame-rate": "Liczba klatek na sekundÄ™",
+ "frame-rate-custom": "Niestandardowa",
+ "frame-rate-desc": "Docelowa liczba klatek na sekundę dla nagrań ekranu",
+ "custom-frame-rate": "Niestandardowa liczba klatek",
+ "custom-frame-rate-desc": "Wprowadź niestandardową liczbę klatek na sekundę (FPS)",
+ "quality": "Jakość wideo",
+ "quality-desc": "Wyższa jakość skutkuje większymi rozmiarami plików",
+ "quality-high": "Wysoki",
+ "quality-medium": "Åšredni",
+ "quality-ultra": "Bardzo",
+ "quality-very-high": "Bardzo wysoki",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Źródło wideo",
+ "source-desc": "Zalecany jest Portal, jeśli występują artefakty, spróbuj opcji Ekran",
+ "sources-portal": "Portal",
+ "sources-screen": "Ekran",
+ "title": "Ustawienia wideo",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/pt.json b/.config/noctalia/plugins/screen-recorder/i18n/pt.json
new file mode 100644
index 0000000..04a9efc
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/pt.json
@@ -0,0 +1,96 @@
+{
+ "description": "Gravação de tela acelerada por hardware com configurações personalizáveis de vÃdeo e áudio",
+ "messages": {
+ "failed-general": "O gravador foi encerrado com um erro.",
+ "failed-gpu": "gpu-screen-recorder foi encerrado inesperadamente.",
+ "failed-start": "Falha ao iniciar a gravação.",
+ "no-portals": "Portais de desktop não estão em execução.",
+ "no-portals-desc": "Inicie o xdg-desktop-portal e um portal compositor (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder não instalado.",
+ "not-installed-desc": "Por favor, instale o gpu-screen-recorder para usar os recursos de gravação de tela.",
+ "saved": "Gravação salva.",
+ "start-recording": "Gravador de tela (iniciar gravação)",
+ "started": "Gravação iniciada.",
+ "stop-recording": "Gravador de tela (parar gravação)",
+ "stopping": "Parando a gravação...",
+ "monitor-not-found": "Não foi possÃvel detetar o monitor sob o cursor.",
+ "replay-saved": "Replay salvo.",
+ "replay-started": "Buffer de replay iniciado.",
+ "replay-stopped": "Buffer de replay parado.",
+ "replay-failed": "Falha ao iniciar o buffer de replay.",
+ "replay-active": "Gravador de ecrã (replay ativo)",
+ "save-replay": "Salvar buffer de replay",
+ "start-replay": "Iniciar buffer de replay",
+ "stop-replay": "Parar buffer de replay"
+ },
+ "name": "Gravador de Tela",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "SaÃda do sistema + entrada do microfone",
+ "audio-sources-microphone-input": "Entrada de microfone",
+ "audio-sources-none": "Sem áudio",
+ "audio-sources-system-output": "SaÃda do sistema",
+ "codec": "Codec de áudio",
+ "codec-desc": "Opus é recomendado para melhor desempenho e menor tamanho de áudio",
+ "source": "Fonte de áudio",
+ "source-desc": "Fonte de áudio para capturar durante a gravação",
+ "title": "Configurações de Ãudio"
+ },
+ "filename-pattern": {
+ "description": "Padrão de nome de arquivo usando códigos de data/hora (por exemplo, recording_yyyyMMdd_HHmmss)",
+ "label": "Padrão de nome de arquivo"
+ },
+ "general": {
+ "copy-to-clipboard": "Copiar para área de transferência",
+ "copy-to-clipboard-description": "Copiar o arquivo para a área de transferência após a gravação terminar.",
+ "output-folder": "Pasta de saÃda",
+ "output-folder-description": "Pasta onde as gravações de tela serão salvas",
+ "restore-portal-session": "Restaurar sessão do portal",
+ "restore-portal-session-description": "Restaurar automaticamente a sessão anterior do portal para gravações subsequentes (desativa o aviso de seleção)",
+ "show-cursor": "Mostrar cursor",
+ "show-cursor-description": "Gravar o cursor do mouse no vÃdeo",
+ "title": "Geral"
+ },
+ "replay": {
+ "enable": "Ativar buffer de replay",
+ "enable-desc": "Manter um buffer rotativo da atividade recente do ecrã que pode ser salvo sob demanda",
+ "duration": "Duração do replay",
+ "duration-desc": "Comprimento do buffer de replay em segundos",
+ "custom-duration": "Duração de replay personalizada",
+ "custom-duration-desc": "Insira uma duração de buffer de replay personalizada em segundos",
+ "storage": "Armazenamento do replay",
+ "storage-desc": "Onde armazenar o buffer de replay",
+ "storage-ram": "RAM (recomendado)",
+ "storage-disk": "Disco"
+ },
+ "title": "Configurações do Gravador de Tela",
+ "video": {
+ "codec": "Codec de vÃdeo",
+ "codec-desc": "h264 é o codec mais comum",
+ "color-range": "Faixa de cores",
+ "color-range-desc": "Limitado é recomendado para melhor compatibilidade",
+ "color-range-full": "Completo",
+ "color-range-limited": "Limitado",
+ "frame-rate": "Taxa de quadros",
+ "frame-rate-custom": "Personalizado",
+ "frame-rate-desc": "Taxa de quadros alvo para gravações de tela",
+ "custom-frame-rate": "Taxa de quadros personalizada",
+ "custom-frame-rate-desc": "Insira um valor personalizado de taxa de quadros (FPS)",
+ "quality": "Qualidade de vÃdeo",
+ "quality-desc": "Maior qualidade resulta em arquivos maiores",
+ "quality-high": "Alto(a)",
+ "quality-medium": "Médio",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Muito alto/altÃssima/elevadÃssimo(a)",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Fonte de vÃdeo",
+ "source-desc": "Portal é recomendado, se obtiver artefatos tente Tela",
+ "sources-portal": "Portal",
+ "sources-screen": "Tela",
+ "title": "Configurações de VÃdeo",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/ru.json b/.config/noctalia/plugins/screen-recorder/i18n/ru.json
new file mode 100644
index 0000000..bc4d016
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/ru.json
@@ -0,0 +1,96 @@
+{
+ "description": "Ðппаратно-уÑÐºÐ¾Ñ€ÐµÐ½Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ Ñкрана Ñ Ð½Ð°Ñтраиваемыми параметрами видео и аудио",
+ "messages": {
+ "failed-general": "Рекордер завершил работу Ñ Ð¾ÑˆÐ¸Ð±ÐºÐ¾Ð¹.",
+ "failed-gpu": "gpu-screen-recorder неожиданно завершил работу.",
+ "failed-start": "Ðе удалоÑÑŒ начать запиÑÑŒ.",
+ "no-portals": "Порталы рабочего Ñтола не работают.",
+ "no-portals-desc": "ЗапуÑтить xdg-desktop-portal и портал композитора (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder не уÑтановлен.",
+ "not-installed-desc": "ПожалуйÑта, уÑтановите gpu-screen-recorder, чтобы иÑпользовать функции запиÑи Ñкрана.",
+ "saved": "ЗапиÑÑŒ Ñохранена.",
+ "start-recording": "ЗапиÑÑŒ Ñкрана (начать запиÑÑŒ)",
+ "started": "ЗапиÑÑŒ началаÑÑŒ.",
+ "stop-recording": "ЗапиÑÑŒ Ñкрана (оÑтановить запиÑÑŒ)",
+ "stopping": "ОÑтановка запиÑи…",
+ "monitor-not-found": "Ðе удалоÑÑŒ обнаружить монитор под курÑором.",
+ "replay-saved": "Повтор Ñохранен.",
+ "replay-started": "Буфер повтора запущен.",
+ "replay-stopped": "Буфер повтора оÑтановлен.",
+ "replay-failed": "Ðе удалоÑÑŒ запуÑтить буфер повтора.",
+ "replay-active": "Ð—Ð°Ð¿Ð¸Ñ Ñкрана (повтор активен)",
+ "save-replay": "Сохранить буфер повтора",
+ "start-replay": "ЗапуÑтить буфер повтора",
+ "stop-replay": "ОÑтановить буфер повтора"
+ },
+ "name": "ЗапиÑÑŒ Ðкрана",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Вывод ÑиÑтемы + ввод Ñ Ð¼Ð¸ÐºÑ€Ð¾Ñ„Ð¾Ð½Ð°",
+ "audio-sources-microphone-input": "Микрофонный вход",
+ "audio-sources-none": "Ðет звука",
+ "audio-sources-system-output": "Вывод ÑиÑтемы",
+ "codec": "Ðудиокодек",
+ "codec-desc": "Opus рекомендуетÑÑ Ð´Ð»Ñ Ð»ÑƒÑ‡ÑˆÐµÐ¹ производительноÑти и наименьшего размера аудио",
+ "source": "ИÑточник аудио",
+ "source-desc": "ИÑточник аудио Ð´Ð»Ñ Ð·Ð°Ñ…Ð²Ð°Ñ‚Ð° во Ð²Ñ€ÐµÐ¼Ñ Ð·Ð°Ð¿Ð¸Ñи",
+ "title": "ÐаÑтройки Ðудио"
+ },
+ "filename-pattern": {
+ "description": "Шаблон имени файла Ñ Ð¸Ñпользованием кодов даты/времени (например, recording_yyyyMMdd_HHmmss)",
+ "label": "Шаблон имени файла"
+ },
+ "general": {
+ "copy-to-clipboard": "Копировать в буфер обмена",
+ "copy-to-clipboard-description": "Скопировать файл в буфер обмена поÑле Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð·Ð°Ð¿Ð¸Ñи.",
+ "output-folder": "Папка вывода",
+ "output-folder-description": "Папка, в которую будут ÑохранÑтьÑÑ Ð·Ð°Ð¿Ð¸Ñи Ñкрана",
+ "restore-portal-session": "ВоÑÑтановить ÑÐµÐ°Ð½Ñ Ð¿Ð¾Ñ€Ñ‚Ð°Ð»Ð°",
+ "restore-portal-session-description": "ÐвтоматичеÑки воÑÑтанавливать предыдущий ÑÐµÐ°Ð½Ñ Ð¿Ð¾Ñ€Ñ‚Ð°Ð»Ð° Ð´Ð»Ñ Ð¿Ð¾Ñледующих запиÑей (отключает Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð²Ñ‹Ð±Ð¾Ñ€Ð°)",
+ "show-cursor": "Показать курÑор",
+ "show-cursor-description": "ЗапиÑывать курÑор мыши в видео",
+ "title": "Общие"
+ },
+ "replay": {
+ "enable": "Включить буфер повтора",
+ "enable-desc": "Хранить ÑкользÑщий буфер недавней активноÑти Ñкрана, который можно Ñохранить по требованию",
+ "duration": "ДлительноÑть повтора",
+ "duration-desc": "Длина буфера повтора в Ñекундах",
+ "custom-duration": "ПользовательÑÐºÐ°Ñ Ð´Ð»Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñть повтора",
+ "custom-duration-desc": "Введите пользовательÑкую длительноÑть буфера повтора в Ñекундах",
+ "storage": "Хранилище повтора",
+ "storage-desc": "Где хранить буфер повтора",
+ "storage-ram": "ОЗУ (рекомендуетÑÑ)",
+ "storage-disk": "ДиÑк"
+ },
+ "title": "ÐаÑтройки ЗапиÑи Ðкрана",
+ "video": {
+ "codec": "Видеокодек",
+ "codec-desc": "h264 ÑвлÑетÑÑ Ð½Ð°Ð¸Ð±Ð¾Ð»ÐµÐµ раÑпроÑтраненным кодеком",
+ "color-range": "Цветовой диапазон",
+ "color-range-desc": "Ограниченный рекомендуетÑÑ Ð´Ð»Ñ Ð»ÑƒÑ‡ÑˆÐµÐ¹ ÑовмеÑтимоÑти",
+ "color-range-full": "Полный",
+ "color-range-limited": "Ограниченный",
+ "frame-rate": "ЧаÑтота кадров",
+ "frame-rate-custom": "ПользовательÑкаÑ",
+ "frame-rate-desc": "Ð¦ÐµÐ»ÐµÐ²Ð°Ñ Ñ‡Ð°Ñтота кадров Ð´Ð»Ñ Ð·Ð°Ð¿Ð¸Ñи Ñкрана",
+ "custom-frame-rate": "ПользовательÑÐºÐ°Ñ Ñ‡Ð°Ñтота кадров",
+ "custom-frame-rate-desc": "Введите пользовательÑкое значение чаÑтоты кадров (FPS)",
+ "quality": "КачеÑтво видео",
+ "quality-desc": "Более выÑокое качеÑтво приводит к большим размерам файлов",
+ "quality-high": "Ð’Ñ‹Ñокий",
+ "quality-medium": "Средний",
+ "quality-ultra": "Ультра",
+ "quality-very-high": "Очень выÑокий",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "ИÑточник видео",
+ "source-desc": "РекомендуетÑÑ Portal, еÑли поÑвлÑÑŽÑ‚ÑÑ Ð°Ñ€Ñ‚ÐµÑ„Ð°ÐºÑ‚Ñ‹ попробуйте Ðкран",
+ "sources-portal": "Портал",
+ "sources-screen": "Ðкран",
+ "title": "ÐаÑтройки Видео",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/tr.json b/.config/noctalia/plugins/screen-recorder/i18n/tr.json
new file mode 100644
index 0000000..4303f5e
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/tr.json
@@ -0,0 +1,96 @@
+{
+ "description": "Özelleştirilebilir video ve ses ayarlarıyla donanım hızlandırmalı ekran kaydı",
+ "messages": {
+ "failed-general": "Kaydedici bir hatayla çıktı.",
+ "failed-gpu": "gpu-screen-recorder beklenmedik şekilde çıktı.",
+ "failed-start": "Kayda başlama başarısız oldu.",
+ "no-portals": "Masaüstü portalları çalışmıyor.",
+ "no-portals-desc": "xdg-desktop-portal'ı ve bir compositor portalı başlat (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder kurulu deÄŸil.",
+ "not-installed-desc": "Ekran kaydı özelliklerini kullanmak için lütfen gpu-screen-recorder'ı kurun.",
+ "saved": "Kayıt kaydedildi.",
+ "start-recording": "Ekran kaydedici (kayda baÅŸla)",
+ "started": "Kayıt başladı.",
+ "stop-recording": "Ekran kaydedici (kaydı durdur)",
+ "stopping": "Kaydı durdurma…",
+ "monitor-not-found": "İmleç altındaki monitör algılanamadı.",
+ "replay-saved": "Tekrar kaydedildi.",
+ "replay-started": "Tekrar arabelleği başlatıldı.",
+ "replay-stopped": "Tekrar arabelleÄŸi durduruldu.",
+ "replay-failed": "Tekrar arabelleği başlatılamadı.",
+ "replay-active": "Ekran Kaydedici (tekrar etkin)",
+ "save-replay": "Tekrar arabelleÄŸini kaydet",
+ "start-replay": "Tekrar arabelleÄŸini baÅŸlat",
+ "stop-replay": "Tekrar arabelleÄŸini durdur"
+ },
+ "name": "Ekran Kaydedici",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Sistem çıktısı + mikrofon girişi",
+ "audio-sources-microphone-input": "Mikrofon giriÅŸi",
+ "audio-sources-none": "Ses yok",
+ "audio-sources-system-output": "Sistem çıktısı",
+ "codec": "Ses codec",
+ "codec-desc": "En iyi performans ve en küçük ses boyutu için Opus önerilir",
+ "source": "Ses kaynağı",
+ "source-desc": "Kayıt sırasında yakalanacak ses kaynağı",
+ "title": "Ses Ayarları"
+ },
+ "filename-pattern": {
+ "description": "Tarih/saat kodları kullanan dosya adı deseni (ör. kayit_yyyyMMdd_HHmmss)",
+ "label": "Dosya adı deseni"
+ },
+ "general": {
+ "copy-to-clipboard": "Panoya kopyala",
+ "copy-to-clipboard-description": "Kayıt bittikten sonra dosyayı panoya kopyala.",
+ "output-folder": "Çıktı klasörü",
+ "output-folder-description": "Ekran kayıtlarının kaydedileceği klasör",
+ "restore-portal-session": "Portal oturumunu geri yükle",
+ "restore-portal-session-description": "Sonraki kayıtlar için önceki portal oturumunu otomatik olarak geri yükle (seçim penceresini devre dışı bırakır)",
+ "show-cursor": "İmleci göster",
+ "show-cursor-description": "Videoda fare imlecini kaydet",
+ "title": "Genel"
+ },
+ "replay": {
+ "enable": "Tekrar arabelleÄŸini etkinleÅŸtir",
+ "enable-desc": "İsteğe bağlı olarak kaydedilebilen son ekran etkinliğinin döngüsel bir arabelleğini tutun",
+ "duration": "Tekrar süresi",
+ "duration-desc": "Saniye cinsinden tekrar arabelleÄŸinin uzunluÄŸu",
+ "custom-duration": "Özel tekrar süresi",
+ "custom-duration-desc": "Saniye cinsinden özel bir tekrar arabelleği süresi girin",
+ "storage": "Tekrar depolama",
+ "storage-desc": "Tekrar arabelleğinin nerede saklanacağı",
+ "storage-ram": "RAM (önerilir)",
+ "storage-disk": "Disk"
+ },
+ "title": "Ekran Kaydedici Ayarları",
+ "video": {
+ "codec": "Video codec",
+ "codec-desc": "h264 en yaygın codec'tir",
+ "color-range": "Renk aralığı",
+ "color-range-desc": "Daha iyi uyumluluk için Sınırlı önerilir",
+ "color-range-full": "Tam",
+ "color-range-limited": "Sınırlı",
+ "frame-rate": "Kare hızı",
+ "frame-rate-custom": "Özel",
+ "frame-rate-desc": "Ekran kayıtları için hedef kare hızı",
+ "custom-frame-rate": "Özel kare hızı",
+ "custom-frame-rate-desc": "Özel bir kare hızı değeri girin (FPS)",
+ "quality": "Video kalitesi",
+ "quality-desc": "Daha yüksek kalite daha büyük dosya boyutlarına yol açar",
+ "quality-high": "Yüksek",
+ "quality-medium": "Orta",
+ "quality-ultra": "Ultra",
+ "quality-very-high": "Çok yüksek",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Video kaynağı",
+ "source-desc": "Portal önerilir, artefaktlar alırsanız Ekran'ı deneyin",
+ "sources-portal": "Portal",
+ "sources-screen": "Ekran",
+ "title": "Video Ayarları",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/uk-UA.json b/.config/noctalia/plugins/screen-recorder/i18n/uk-UA.json
new file mode 100644
index 0000000..71e04ce
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/uk-UA.json
@@ -0,0 +1,96 @@
+{
+ "description": "Ðпаратно-приÑкорений Ð·Ð°Ð¿Ð¸Ñ ÐµÐºÑ€Ð°Ð½Ñƒ з налаштовуваними параметрами відео та аудіо",
+ "messages": {
+ "failed-general": "Рекордер завершив роботу з помилкою.",
+ "failed-gpu": "gpu-screen-recorder неÑподівано завершив роботу.",
+ "failed-start": "Ðе вдалоÑÑ Ð¿Ð¾Ñ‡Ð°Ñ‚Ð¸ запиÑ.",
+ "no-portals": "ÐаÑтільні портали не працюють.",
+ "no-portals-desc": "ЗапуÑтити xdg-desktop-portal та портал композитора (wlr/hyprland/gnome/kde).",
+ "not-installed": "gpu-screen-recorder не вÑтановлено.",
+ "not-installed-desc": "Будь лаÑка, вÑтановіть gpu-screen-recorder, щоб викориÑтовувати функції запиÑу екрана.",
+ "saved": "Ð—Ð°Ð¿Ð¸Ñ Ð·Ð±ÐµÑ€ÐµÐ¶ÐµÐ½Ð¾.",
+ "start-recording": "Ð—Ð°Ð¿Ð¸Ñ ÐµÐºÑ€Ð°Ð½Ð° (почати запиÑ)",
+ "started": "Ð—Ð°Ð¿Ð¸Ñ Ñ€Ð¾Ð·Ð¿Ð¾Ñ‡Ð°Ñ‚Ð¾.",
+ "stop-recording": "Ð—Ð°Ð¿Ð¸Ñ ÐµÐºÑ€Ð°Ð½Ð° (зупинити запиÑ)",
+ "stopping": "Зупинка запиÑу…",
+ "monitor-not-found": "Ðе вдалоÑÑ Ð²Ð¸Ñвити монітор під курÑором.",
+ "replay-saved": "Повтор збережено.",
+ "replay-started": "Буфер повтору запущено.",
+ "replay-stopped": "Буфер повтору зупинено.",
+ "replay-failed": "Ðе вдалоÑÑ Ð·Ð°Ð¿ÑƒÑтити буфер повтору.",
+ "replay-active": "Ð—Ð°Ð¿Ð¸Ñ ÐµÐºÑ€Ð°Ð½Ð° (повтор активний)",
+ "save-replay": "Зберегти буфер повтору",
+ "start-replay": "ЗапуÑтити буфер повтору",
+ "stop-replay": "Зупинити буфер повтору"
+ },
+ "name": "ЗапиÑувач Екрану",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Вивід ÑиÑтеми + вхід мікрофона",
+ "audio-sources-microphone-input": "Мікрофонний вхід",
+ "audio-sources-none": "Ðемає аудіо",
+ "audio-sources-system-output": "Вивід ÑиÑтеми",
+ "codec": "Ðудіокодек",
+ "codec-desc": "Opus рекомендуєтьÑÑ Ð´Ð»Ñ Ð½Ð°Ð¹ÐºÑ€Ð°Ñ‰Ð¾Ñ— продуктивноÑті та найменшого розміру аудіо",
+ "source": "Джерело аудіо",
+ "source-desc": "Джерело аудіо Ð´Ð»Ñ Ð·Ð°Ñ…Ð¾Ð¿Ð»ÐµÐ½Ð½Ñ Ð¿Ñ–Ð´ Ñ‡Ð°Ñ Ð·Ð°Ð¿Ð¸Ñу",
+ "title": "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ðудіо"
+ },
+ "filename-pattern": {
+ "description": "Шаблон імені файлу з викориÑтаннÑм кодів дати/чаÑу (наприклад, recording_yyyyMMdd_HHmmss)",
+ "label": "Шаблон імені файлу"
+ },
+ "general": {
+ "copy-to-clipboard": "Копіювати до буфера обміну",
+ "copy-to-clipboard-description": "Копіювати файл у буфер обміну піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñу.",
+ "output-folder": "Папка виводу",
+ "output-folder-description": "Папка, у Ñкій будуть зберігатиÑÑ Ð·Ð°Ð¿Ð¸Ñи екрана",
+ "restore-portal-session": "Відновити ÑÐµÐ°Ð½Ñ Ð¿Ð¾Ñ€Ñ‚Ð°Ð»Ñƒ",
+ "restore-portal-session-description": "Ðвтоматично відновлювати попередній ÑÐµÐ°Ð½Ñ Ð¿Ð¾Ñ€Ñ‚Ð°Ð»Ñƒ Ð´Ð»Ñ Ð½Ð°Ñтупних запиÑів (вимикає запит вибору)",
+ "show-cursor": "Показати курÑор",
+ "show-cursor-description": "ЗапиÑувати курÑор миші у відео",
+ "title": "Загальні"
+ },
+ "replay": {
+ "enable": "Увімкнути буфер повтору",
+ "enable-desc": "Зберігати циклічний буфер оÑтанньої активноÑті екрана, Ñкий можна зберегти на вимогу",
+ "duration": "ТриваліÑть повтору",
+ "duration-desc": "Довжина буфера повтору в Ñекундах",
+ "custom-duration": "Ð¡Ð²Ð¾Ñ Ñ‚Ñ€Ð¸Ð²Ð°Ð»Ñ–Ñть повтору",
+ "custom-duration-desc": "Введіть влаÑну триваліÑть буфера повтору в Ñекундах",
+ "storage": "Сховище повтору",
+ "storage-desc": "Де зберігати буфер повтору",
+ "storage-ram": "ОЗП (рекомендовано)",
+ "storage-disk": "ДиÑк"
+ },
+ "title": "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð—Ð°Ð¿Ð¸Ñувача Екрану",
+ "video": {
+ "codec": "Відеокодек",
+ "codec-desc": "h264 є найпоширенішим кодеком",
+ "color-range": "Кольоровий діапазон",
+ "color-range-desc": "Обмежений рекомендуєтьÑÑ Ð´Ð»Ñ ÐºÑ€Ð°Ñ‰Ð¾Ñ— ÑуміÑноÑті",
+ "color-range-full": "Повний",
+ "color-range-limited": "Обмежений",
+ "frame-rate": "ЧаÑтота кадрів",
+ "frame-rate-custom": "КориÑтувацька",
+ "frame-rate-desc": "Цільова чаÑтота кадрів Ð´Ð»Ñ Ð·Ð°Ð¿Ð¸Ñу екрану",
+ "custom-frame-rate": "КориÑтувацька чаÑтота кадрів",
+ "custom-frame-rate-desc": "Введіть кориÑтувацьке Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ñ‡Ð°Ñтоти кадрів (FPS)",
+ "quality": "ЯкіÑть відео",
+ "quality-desc": "Вища ÑкіÑть призводить до більших розмірів файлів",
+ "quality-high": "ВиÑокий",
+ "quality-medium": "Середній",
+ "quality-ultra": "Ультра",
+ "quality-very-high": "Дуже виÑокий",
+ "resolution": "Resolution",
+ "resolution-desc": "Limit output resolution. Default uses original screen resolution",
+ "resolution-original": "Default",
+ "source": "Джерело відео",
+ "source-desc": "РекомендуєтьÑÑ Portal, Ñкщо з'ÑвлÑютьÑÑ Ð°Ñ€Ñ‚ÐµÑ„Ð°ÐºÑ‚Ð¸ Ñпробуйте Екран",
+ "sources-portal": "Портал",
+ "sources-screen": "Екран",
+ "title": "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð’Ñ–Ð´ÐµÐ¾",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/vi.json b/.config/noctalia/plugins/screen-recorder/i18n/vi.json
new file mode 100644
index 0000000..7f64828
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/vi.json
@@ -0,0 +1,99 @@
+{
+ "description": "Ghi mà n hình bằng phần cứng với các tùy chỉnh video và âm thanh",
+ "messages": {
+ "failed-general": "Trình ghi đã thoát với lỗi.",
+ "failed-gpu": "gpu-screen-recorder đã thoát bất ngá».",
+ "failed-start": "Không thể bắt đầu ghi.",
+ "no-portals": "Desktop portal chưa chạy.",
+ "no-portals-desc": "Hãy khởi động xdg-desktop-portal và portal compositor (wlr/hyprland/gnome/kde).",
+ "not-installed": "Chưa cà i đặt gpu-screen-recorder.",
+ "not-installed-desc": "Vui lòng cà i đặt gpu-screen-recorder để sá» dụng tÃnh năng ghi mà n hình.",
+ "saved": "Äã lưu bản ghi.",
+ "start-recording": "Trình ghi mà n hình (bắt đầu ghi)",
+ "started": "Äã bắt đầu ghi.",
+ "stop-recording": "Trình ghi mà n hình (dừng ghi)",
+ "stopping": "Äang dừng ghi…",
+ "open-file": "Mở tệp",
+ "monitor-not-found": "Không thể xác định mà n hình dưới con trá».",
+ "replay-saved": "Äã lưu replay.",
+ "replay-started": "Äã bắt đầu bá»™ đệm replay.",
+ "replay-stopped": "Äã dừng bá»™ đệm replay.",
+ "replay-failed": "Không thể bắt đầu bộ đệm replay.",
+ "replay-active": "Trình ghi mà n hình (replay đang hoạt động)",
+ "save-replay": "Lưu bộ đệm replay",
+ "start-replay": "Bắt đầu bộ đệm replay",
+ "stop-replay": "Dừng bộ đệm replay"
+ },
+ "name": "Trình ghi mà n hình",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "Âm thanh hệ thống + micro",
+ "audio-sources-microphone-input": "Micro",
+ "audio-sources-none": "Không có âm thanh",
+ "audio-sources-system-output": "Âm thanh hệ thống",
+ "codec": "Codec âm thanh",
+ "codec-desc": "Khuyến nghị dùng Opus để có hiệu năng tốt và kÃch thước nhá»",
+ "source": "Nguồn âm thanh",
+ "source-desc": "Nguồn âm thanh sẽ được ghi",
+ "title": "Cà i đặt âm thanh"
+ },
+ "filename-pattern": {
+ "description": "Mẫu tên tệp sỠdụng định dạng ngà y giỠ(và dụ: recording_yyyyMMdd_HHmmss)",
+ "label": "Mẫu tên tệp"
+ },
+ "general": {
+ "copy-to-clipboard": "Sao chép và o clipboard",
+ "copy-to-clipboard-description": "Sao chép tệp và o clipboard sau khi ghi xong.",
+ "hide-when-inactive": "Ẩn khi không hoạt động",
+ "hide-when-inactive-description": "Ẩn chỉ báo trên thanh khi không ghi",
+ "output-folder": "Thư mục lưu",
+ "output-folder-description": "Thư mục lưu các bản ghi mà n hình",
+ "restore-portal-session": "Khôi phục phiên portal",
+ "restore-portal-session-description": "Tá»± động khôi phục phiên portal trước đó cho lần ghi tiếp theo (tắt há»™p thoại chá»n)",
+ "show-cursor": "Hiển thị con trá»",
+ "show-cursor-description": "Ghi lại con trỠchuột trong video",
+ "title": "Chung"
+ },
+ "replay": {
+ "enable": "Báºt replay buffer",
+ "enable-desc": "Lưu tạm hoạt động mà n hình gần đây để có thể lưu lại khi cần",
+ "duration": "Thá»i lượng replay",
+ "duration-desc": "Äá»™ dà i bá»™ đệm replay tÃnh bằng giây",
+ "custom-duration": "Thá»i lượng tùy chỉnh",
+ "custom-duration-desc": "Nháºp thá»i lượng replay tùy chỉnh (giây)",
+ "storage": "Lưu trữ replay",
+ "storage-desc": "Nơi lưu bộ đệm replay",
+ "storage-ram": "RAM (khuyến nghị)",
+ "storage-disk": "ÄÄ©a"
+ },
+ "title": "Cà i đặt trình ghi mà n hình",
+ "video": {
+ "codec": "Codec video",
+ "codec-desc": "h264 là codec phổ biến nhất",
+ "color-range": "Dải mà u",
+ "color-range-desc": "Khuyến nghị dùng Limited để tương thÃch tốt hÆ¡n",
+ "color-range-full": "Äầy đủ",
+ "color-range-limited": "Giới hạn",
+ "frame-rate": "Tốc độ khung hình",
+ "frame-rate-custom": "Tùy chỉnh",
+ "frame-rate-desc": "Tốc độ khung hình mục tiêu khi ghi",
+ "custom-frame-rate": "Tốc độ khung hình tùy chỉnh",
+ "custom-frame-rate-desc": "Nháºp giá trị FPS tùy chỉnh",
+ "quality": "Chất lượng video",
+ "quality-desc": "Chất lượng cao hơn sẽ tạo tệp lớn hơn",
+ "quality-high": "Cao",
+ "quality-medium": "Trung bình",
+ "quality-ultra": "Siêu cao",
+ "quality-very-high": "Rất cao",
+ "resolution": "Äá»™ phân giải",
+ "resolution-desc": "Giới hạn độ phân giải đầu ra. Mặc định dùng độ phân giải gốc",
+ "resolution-original": "Mặc định",
+ "source": "Nguồn video",
+ "source-desc": "Khuyến nghị dùng Portal, nếu lỗi hiển thị thì thỠScreen",
+ "sources-portal": "Portal",
+ "sources-screen": "Mà n hình",
+ "sources-focused-monitor": "Mà n hình đang focus (Hyprland)",
+ "title": "Cà i đặt video"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/i18n/zh-CN.json b/.config/noctalia/plugins/screen-recorder/i18n/zh-CN.json
new file mode 100644
index 0000000..0d6ad73
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/i18n/zh-CN.json
@@ -0,0 +1,99 @@
+{
+ "description": "具有å¯è‡ªå®šä¹‰è§†é¢‘å’ŒéŸ³é¢‘è®¾ç½®çš„ç¡¬ä»¶åŠ é€Ÿå±å¹•录制",
+ "messages": {
+ "failed-general": "录制器出错退出。",
+ "failed-gpu": "gpu-screen-recorder æ„外退出。",
+ "failed-start": "æ— æ³•å¼€å§‹å½•åˆ¶ã€‚",
+ "no-portals": "桌é¢é—¨æˆ·æœªè¿è¡Œã€‚",
+ "no-portals-desc": "å¯åЍ xdg-desktop-portal å’Œä¸€ä¸ªåˆæˆå™¨é—¨æˆ· (wlr/hyprland/gnome/kde)。",
+ "not-installed": "gpu-screen-recorder 未安装。",
+ "not-installed-desc": "请安装 gpu-screen-recorder 以使用å±å¹•录制功能。",
+ "saved": "å·²ä¿å˜å½•åƒã€‚",
+ "start-recording": "å±å¹•录åƒï¼ˆå¼€å§‹å½•制)",
+ "started": "已开始录制。",
+ "stop-recording": "å±å¹•录åƒï¼ˆåœæ¢å½•制)",
+ "stopping": "åœæ¢å½•制…",
+ "open-file": "打开文件",
+ "monitor-not-found": "æ— æ³•æ£€æµ‹åˆ°å…‰æ ‡ä¸‹çš„æ˜¾ç¤ºå™¨ã€‚",
+ "replay-saved": "回放已ä¿å˜ã€‚",
+ "replay-started": "回放缓冲已å¯åŠ¨ã€‚",
+ "replay-stopped": "å›žæ”¾ç¼“å†²å·²åœæ¢ã€‚",
+ "replay-failed": "å¯åŠ¨å›žæ”¾ç¼“å†²å¤±è´¥ã€‚",
+ "replay-active": "å±å¹•å½•åƒæœºï¼ˆå›žæ”¾æ´»åЍä¸ï¼‰",
+ "save-replay": "ä¿å˜å›žæ”¾ç¼“冲",
+ "start-replay": "å¯åŠ¨å›žæ”¾ç¼“å†²",
+ "stop-replay": "åœæ¢å›žæ”¾ç¼“冲"
+ },
+ "name": "å±å¹•录制器",
+ "settings": {
+ "audio": {
+ "audio-sources-both": "系统输出 + 麦克风输入",
+ "audio-sources-microphone-input": "麦克风输入",
+ "audio-sources-none": "æ— éŸ³é¢‘",
+ "audio-sources-system-output": "系统输出",
+ "codec": "音频编解ç 器",
+ "codec-desc": "建议使用 Opus 以获得最佳性能和最å°éŸ³é¢‘大å°",
+ "source": "音频æº",
+ "source-desc": "å½•åˆ¶æœŸé—´è¦æ•获的音频æº",
+ "title": "音频设置"
+ },
+ "filename-pattern": {
+ "description": "使用日期/时间代ç çš„æ–‡ä»¶åæ¨¡å¼ (例如,recording_yyyyMMdd_HHmmss)",
+ "label": "æ–‡ä»¶åæ¨¡å¼"
+ },
+ "general": {
+ "copy-to-clipboard": "å¤åˆ¶åˆ°å‰ªè´´æ¿",
+ "copy-to-clipboard-description": "录制结æŸåŽå°†æ–‡ä»¶å¤åˆ¶åˆ°å‰ªè´´æ¿ã€‚",
+ "output-folder": "输出文件夹",
+ "output-folder-description": "å±å¹•录åƒä¿å˜çš„æ–‡ä»¶å¤¹",
+ "restore-portal-session": "æ¢å¤é—¨æˆ·ä¼šè¯",
+ "restore-portal-session-description": "自动æ¢å¤ä¹‹å‰çš„门户会è¯ä»¥ç”¨äºŽåŽç»å½•制(ç¦ç”¨é€‰æ‹©æç¤ºï¼‰",
+ "show-cursor": "æ˜¾ç¤ºå…‰æ ‡",
+ "show-cursor-description": "在视频ä¸å½•åˆ¶é¼ æ ‡å…‰æ ‡",
+ "hide-when-inactive": "éžæ´»åŠ¨æ—¶éšè—",
+ "hide-when-inactive-description": "未录制时éšè—æ æŒ‡ç¤ºå™¨",
+ "title": "常规"
+ },
+ "replay": {
+ "enable": "å¯ç”¨å›žæ”¾ç¼“冲",
+ "enable-desc": "ä¿æŒæœ€è¿‘å±å¹•æ´»åŠ¨çš„æ»šåŠ¨ç¼“å†²ï¼Œå¯æ ¹æ®éœ€è¦ä¿å˜",
+ "duration": "回放时长",
+ "duration-desc": "回放缓冲的长度(秒)",
+ "custom-duration": "自定义回放时长",
+ "custom-duration-desc": "输入自定义回放缓冲时长(秒)",
+ "storage": "回放å˜å‚¨",
+ "storage-desc": "回放缓冲å˜å‚¨ä½ç½®",
+ "storage-ram": "å†…å˜ (推è)",
+ "storage-disk": "ç£ç›˜"
+ },
+ "title": "å±å¹•录制器设置",
+ "video": {
+ "codec": "视频编解ç 器",
+ "codec-desc": "h264 是最常è§çš„ç¼–è§£ç 器",
+ "color-range": "色彩范围",
+ "color-range-desc": "建议使用“有é™â€ä»¥èŽ·å¾—æ›´å¥½çš„å…¼å®¹æ€§",
+ "color-range-full": "全范围",
+ "color-range-limited": "有é™",
+ "frame-rate": "帧率",
+ "frame-rate-custom": "自定义",
+ "frame-rate-desc": "å±å¹•å½•åˆ¶çš„ç›®æ ‡å¸§çŽ‡",
+ "custom-frame-rate": "自定义帧率",
+ "custom-frame-rate-desc": "输入自定义帧率值(FPS)",
+ "quality": "视频质é‡",
+ "quality-desc": "更高的质é‡ä¼šå¯¼è‡´æ›´å¤§çš„æ–‡ä»¶å¤§å°",
+ "quality-high": "高",
+ "quality-medium": "ä¸ç‰",
+ "quality-ultra": "æžé«˜",
+ "quality-very-high": "éžå¸¸é«˜",
+ "resolution": "分辨率",
+ "resolution-desc": "é™åˆ¶è¾“出分辨率。默认使用原始å±å¹•分辨率",
+ "resolution-original": "默认",
+ "source": "视频æº",
+ "source-desc": "推è使用桌é¢é—¨æˆ· (Portal),如果出现画é¢å¼‚常请å°è¯•å±å¹•",
+ "sources-portal": "桌é¢é—¨æˆ·",
+ "sources-screen": "å±å¹•",
+ "title": "视频设置",
+ "sources-focused-monitor": "Focused monitor (Hyprland)"
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/manifest.json b/.config/noctalia/plugins/screen-recorder/manifest.json
new file mode 100644
index 0000000..cb8a0ee
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/manifest.json
@@ -0,0 +1,47 @@
+{
+ "id": "screen-recorder",
+ "name": "Screen Recorder",
+ "version": "1.3.7",
+ "minNoctaliaVersion": "3.6.0",
+ "author": "Noctalia Team",
+ "official": true,
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "Hardware-accelerated screen recording using gpu-screen-recorder with customizable video and audio settings",
+ "tags": [
+ "Bar",
+ "Utility"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "controlCenterWidget": "ControlCenterWidget.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {
+ "hideInactive": false,
+ "iconColor": "none",
+ "directory": "",
+ "filenamePattern": "recording_yyyyMMdd_HHmmss",
+ "frameRate": "60",
+ "audioCodec": "opus",
+ "videoCodec": "h264",
+ "quality": "very_high",
+ "colorRange": "limited",
+ "showCursor": true,
+ "copyToClipboard": false,
+ "audioSource": "default_output",
+ "videoSource": "portal",
+ "resolution": "original",
+ "replayEnabled": false,
+ "replayDuration": "30",
+ "customReplayDuration": "30",
+ "replayStorage": "ram",
+ "restorePortalSession": false
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/screen-recorder/settings.json b/.config/noctalia/plugins/screen-recorder/settings.json
new file mode 100644
index 0000000..4249b72
--- /dev/null
+++ b/.config/noctalia/plugins/screen-recorder/settings.json
@@ -0,0 +1,22 @@
+{
+ "hideInactive": false,
+ "iconColor": "primary",
+ "directory": "/home/dox/obs",
+ "filenamePattern": "recording_yyyyMMdd_HHmmss",
+ "frameRate": "30",
+ "audioCodec": "opus",
+ "videoCodec": "hevc",
+ "quality": "ultra",
+ "colorRange": "limited",
+ "showCursor": true,
+ "copyToClipboard": true,
+ "audioSource": "default_output",
+ "videoSource": "portal",
+ "resolution": "1920x1080",
+ "replayEnabled": false,
+ "replayDuration": "30",
+ "customReplayDuration": "30",
+ "replayStorage": "ram",
+ "restorePortalSession": false,
+ "customFrameRate": "60"
+}
diff --git a/.config/noctalia/settings.json b/.config/noctalia/settings.json
new file mode 100644
index 0000000..2ae2cbb
--- /dev/null
+++ b/.config/noctalia/settings.json
@@ -0,0 +1,882 @@
+{
+ "appLauncher": {
+ "autoPasteClipboard": false,
+ "clipboardWatchImageCommand": "wl-paste --type image/png --watch cliphist store",
+ "clipboardWatchTextCommand": "wl-paste --type text --watch cliphist store",
+ "clipboardWrapText": true,
+ "customLaunchPrefix": "",
+ "customLaunchPrefixEnabled": false,
+ "density": "default",
+ "enableClipPreview": true,
+ "enableClipboardChips": true,
+ "enableClipboardHistory": true,
+ "enableClipboardSmartIcons": true,
+ "enableSessionSearch": false,
+ "enableSettingsSearch": false,
+ "enableWindowsSearch": false,
+ "iconMode": "native",
+ "ignoreMouseInput": false,
+ "overviewLayer": false,
+ "pinnedApps": [
+ ],
+ "position": "center",
+ "screenshotAnnotationTool": "",
+ "showCategories": false,
+ "showIconBackground": false,
+ "sortByMostUsed": true,
+ "terminalCommand": "alacritty -e",
+ "viewMode": "list"
+ },
+ "audio": {
+ "mprisBlacklist": [
+ ],
+ "preferredPlayer": "",
+ "spectrumFrameRate": 30,
+ "spectrumMirrored": true,
+ "visualizerType": "wave",
+ "volumeFeedback": false,
+ "volumeFeedbackSoundFile": "",
+ "volumeOverdrive": false,
+ "volumeStep": 5
+ },
+ "bar": {
+ "autoHideDelay": 500,
+ "autoShowDelay": 150,
+ "backgroundOpacity": 0.93,
+ "barType": "floating",
+ "capsuleColorKey": "none",
+ "capsuleOpacity": 1,
+ "contentPadding": 2,
+ "density": "default",
+ "displayMode": "always_visible",
+ "enableExclusionZoneInset": true,
+ "fontScale": 1,
+ "frameRadius": 12,
+ "frameThickness": 8,
+ "hideOnOverview": false,
+ "marginHorizontal": 200,
+ "marginVertical": 3,
+ "middleClickAction": "none",
+ "middleClickCommand": "",
+ "middleClickFollowMouse": false,
+ "monitors": [
+ ],
+ "mouseWheelAction": "none",
+ "mouseWheelWrap": true,
+ "outerCorners": true,
+ "position": "top",
+ "reverseScroll": false,
+ "rightClickAction": "controlCenter",
+ "rightClickCommand": "",
+ "rightClickFollowMouse": true,
+ "screenOverrides": [
+ ],
+ "showCapsule": true,
+ "showOnWorkspaceSwitch": true,
+ "showOutline": false,
+ "useSeparateOpacity": false,
+ "widgetSpacing": 4,
+ "widgets": {
+ "center": [
+ {
+ "defaultSettings": {
+ "hideBackground": false,
+ "minimumThreshold": 10
+ },
+ "id": "plugin:catwalk"
+ },
+ {
+ "characterCount": 2,
+ "colorizeIcons": false,
+ "emptyColor": "secondary",
+ "enableScrollWheel": true,
+ "focusedColor": "primary",
+ "followFocusedScreen": false,
+ "fontWeight": "bold",
+ "groupedBorderOpacity": 1,
+ "hideUnoccupied": false,
+ "iconScale": 0.8,
+ "id": "Workspace",
+ "labelMode": "none",
+ "occupiedColor": "secondary",
+ "pillSize": 0.63,
+ "showApplications": true,
+ "showApplicationsHover": true,
+ "showBadge": true,
+ "showLabelsOnlyWhenOccupied": true,
+ "unfocusedIconsOpacity": 1
+ },
+ {
+ "compactMode": false,
+ "hideMode": "hidden",
+ "hideWhenIdle": false,
+ "id": "MediaMini",
+ "maxWidth": 145,
+ "panelShowAlbumArt": true,
+ "scrollingMode": "hover",
+ "showAlbumArt": true,
+ "showArtistFirst": true,
+ "showProgressRing": true,
+ "showVisualizer": true,
+ "textColor": "none",
+ "useFixedWidth": false,
+ "visualizerType": "wave"
+ },
+ {
+ "defaultSettings": {
+ "activeColor": "primary",
+ "camFilterRegex": "",
+ "enableToast": true,
+ "hideInactive": false,
+ "iconSpacing": 4,
+ "inactiveColor": "none",
+ "micFilterRegex": "",
+ "removeMargins": false
+ },
+ "id": "plugin:privacy-indicator"
+ },
+ {
+ "defaultSettings": {
+ "audioCodec": "opus",
+ "audioSource": "default_output",
+ "colorRange": "limited",
+ "copyToClipboard": false,
+ "customReplayDuration": "30",
+ "directory": "",
+ "filenamePattern": "recording_yyyyMMdd_HHmmss",
+ "frameRate": "60",
+ "hideInactive": false,
+ "iconColor": "none",
+ "quality": "very_high",
+ "replayDuration": "30",
+ "replayEnabled": false,
+ "replayStorage": "ram",
+ "resolution": "original",
+ "restorePortalSession": false,
+ "showCursor": true,
+ "videoCodec": "h264",
+ "videoSource": "portal"
+ },
+ "id": "plugin:screen-recorder"
+ }
+ ],
+ "left": [
+ {
+ "colorizeSystemIcon": "error",
+ "colorizeSystemText": "none",
+ "customIconPath": "",
+ "enableColorization": true,
+ "icon": "rocket",
+ "iconColor": "none",
+ "id": "Launcher",
+ "useDistroLogo": true
+ },
+ {
+ "compactMode": false,
+ "diskPath": "/",
+ "iconColor": "none",
+ "id": "SystemMonitor",
+ "showCpuCores": false,
+ "showCpuFreq": false,
+ "showCpuTemp": true,
+ "showCpuUsage": true,
+ "showDiskAvailable": false,
+ "showDiskUsage": false,
+ "showDiskUsageAsPercent": false,
+ "showGpuTemp": false,
+ "showLoadAverage": false,
+ "showMemoryAsPercent": false,
+ "showMemoryUsage": true,
+ "showNetworkStats": false,
+ "showSwapUsage": false,
+ "textColor": "none",
+ "useMonospaceFont": true,
+ "usePadding": true
+ },
+ {
+ "colorizeIcons": false,
+ "hideMode": "hidden",
+ "id": "ActiveWindow",
+ "maxWidth": 145,
+ "scrollingMode": "hover",
+ "showIcon": true,
+ "showText": true,
+ "textColor": "none",
+ "useFixedWidth": false
+ }
+ ],
+ "right": [
+ {
+ "blacklist": [
+ ],
+ "chevronColor": "error",
+ "colorizeIcons": false,
+ "drawerEnabled": false,
+ "hidePassive": false,
+ "id": "Tray",
+ "pinned": [
+ ]
+ },
+ {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "id": "Network",
+ "textColor": "none"
+ },
+ {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "id": "Bluetooth",
+ "textColor": "none"
+ },
+ {
+ "deviceNativePath": "__default__",
+ "displayMode": "icon-always",
+ "hideIfIdle": false,
+ "hideIfNotDetected": false,
+ "id": "Battery",
+ "showNoctaliaPerformance": true,
+ "showPowerProfiles": true
+ },
+ {
+ "clockColor": "none",
+ "customFont": "",
+ "formatHorizontal": "HH:mm ddd, MMM d",
+ "formatVertical": "HH mm - dd MM",
+ "id": "Clock",
+ "tooltipFormat": "HH:mm ddd, MMM dd",
+ "useCustomFont": false
+ },
+ {
+ "colorizeDistroLogo": false,
+ "colorizeSystemIcon": "none",
+ "colorizeSystemText": "none",
+ "customIconPath": "",
+ "enableColorization": false,
+ "icon": "noctalia",
+ "id": "ControlCenter",
+ "useDistroLogo": false
+ }
+ ]
+ }
+ },
+ "brightness": {
+ "backlightDeviceMappings": [
+ ],
+ "brightnessStep": 5,
+ "enableDdcSupport": false,
+ "enforceMinimum": true
+ },
+ "calendar": {
+ "cards": [
+ {
+ "enabled": true,
+ "id": "calendar-header-card"
+ },
+ {
+ "enabled": true,
+ "id": "calendar-month-card"
+ },
+ {
+ "enabled": true,
+ "id": "weather-card"
+ }
+ ]
+ },
+ "colorSchemes": {
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "manualSunrise": "06:30",
+ "manualSunset": "18:30",
+ "monitorForColors": "",
+ "predefinedScheme": "Solarized",
+ "schedulingMode": "off",
+ "syncGsettings": true,
+ "useWallpaperColors": true
+ },
+ "controlCenter": {
+ "cards": [
+ {
+ "enabled": true,
+ "id": "profile-card"
+ },
+ {
+ "enabled": true,
+ "id": "shortcuts-card"
+ },
+ {
+ "enabled": true,
+ "id": "audio-card"
+ },
+ {
+ "enabled": true,
+ "id": "brightness-card"
+ },
+ {
+ "enabled": true,
+ "id": "weather-card"
+ },
+ {
+ "enabled": true,
+ "id": "media-sysmon-card"
+ }
+ ],
+ "diskPath": "/",
+ "position": "top_right",
+ "shortcuts": {
+ "left": [
+ {
+ "id": "Network"
+ },
+ {
+ "id": "Bluetooth"
+ },
+ {
+ "id": "WallpaperSelector"
+ },
+ {
+ "id": "NoctaliaPerformance"
+ }
+ ],
+ "right": [
+ {
+ "id": "Notifications"
+ },
+ {
+ "id": "PowerProfile"
+ },
+ {
+ "id": "KeepAwake"
+ },
+ {
+ "id": "NightLight"
+ },
+ {
+ "id": "plugin:dns-switcher"
+ }
+ ]
+ }
+ },
+ "desktopWidgets": {
+ "enabled": true,
+ "gridSnap": false,
+ "gridSnapScale": false,
+ "monitorWidgets": [
+ {
+ "name": "eDP-1",
+ "widgets": [
+ ]
+ }
+ ],
+ "overviewEnabled": true
+ },
+ "dock": {
+ "animationSpeed": 1,
+ "backgroundOpacity": 0,
+ "colorizeIcons": false,
+ "deadOpacity": 0.6,
+ "displayMode": "auto_hide",
+ "dockType": "floating",
+ "enabled": true,
+ "floatingRatio": 0,
+ "groupApps": false,
+ "groupClickAction": "cycle",
+ "groupContextMenuMode": "extended",
+ "groupIndicatorStyle": "dots",
+ "inactiveIndicators": true,
+ "indicatorColor": "primary",
+ "indicatorOpacity": 0.6,
+ "indicatorThickness": 3,
+ "launcherIcon": "",
+ "launcherIconColor": "none",
+ "launcherPosition": "start",
+ "launcherUseDistroLogo": true,
+ "monitors": [
+ "eDP-1"
+ ],
+ "onlySameOutput": true,
+ "pinnedApps": [
+ "kitty",
+ "zen"
+ ],
+ "pinnedStatic": true,
+ "position": "bottom",
+ "showDockIndicator": true,
+ "showLauncherIcon": true,
+ "sitOnFrame": false,
+ "size": 1
+ },
+ "general": {
+ "allowPanelsOnScreenWithoutBar": true,
+ "allowPasswordWithFprintd": false,
+ "animationDisabled": false,
+ "animationSpeed": 1,
+ "autoStartAuth": false,
+ "avatarImage": "/home/dox/Downloads/38E5F036-13D1-450C-9967-5D789EB8B64D-2.png",
+ "boxRadiusRatio": 1,
+ "clockFormat": "hh\\nmm",
+ "clockStyle": "custom",
+ "compactLockScreen": false,
+ "dimmerOpacity": 0.36,
+ "enableBlurBehind": true,
+ "enableLockScreenCountdown": true,
+ "enableLockScreenMediaControls": true,
+ "enableShadows": true,
+ "forceBlackScreenCorners": false,
+ "iRadiusRatio": 1,
+ "keybinds": {
+ "keyDown": [
+ "Down",
+ "Ctrl+J"
+ ],
+ "keyEnter": [
+ "Return",
+ "Enter"
+ ],
+ "keyEscape": [
+ "Esc"
+ ],
+ "keyLeft": [
+ "Left",
+ "Ctrl+H"
+ ],
+ "keyRemove": [
+ "Del"
+ ],
+ "keyRight": [
+ "Right",
+ "Ctrl+L"
+ ],
+ "keyUp": [
+ "Up",
+ "Ctrl+K"
+ ]
+ },
+ "language": "",
+ "lockOnSuspend": true,
+ "lockScreenAnimations": false,
+ "lockScreenBlur": 0.72,
+ "lockScreenCountdownDuration": 10000,
+ "lockScreenMonitors": [
+ ],
+ "lockScreenTint": 0,
+ "passwordChars": false,
+ "radiusRatio": 1,
+ "reverseScroll": false,
+ "scaleRatio": 1,
+ "screenRadiusRatio": 1,
+ "shadowDirection": "bottom",
+ "shadowOffsetX": 0,
+ "shadowOffsetY": 3,
+ "showChangelogOnStartup": true,
+ "showHibernateOnLockScreen": false,
+ "showScreenCorners": false,
+ "showSessionButtonsOnLockScreen": true,
+ "smoothScrollEnabled": true,
+ "telemetryEnabled": false
+ },
+ "hooks": {
+ "colorGeneration": "",
+ "darkModeChange": "",
+ "enabled": false,
+ "performanceModeDisabled": "",
+ "performanceModeEnabled": "",
+ "screenLock": "",
+ "screenUnlock": "",
+ "session": "",
+ "startup": "",
+ "wallpaperChange": ""
+ },
+ "idle": {
+ "customCommands": "[]",
+ "enabled": true,
+ "fadeDuration": 6,
+ "lockCommand": "",
+ "lockTimeout": 660,
+ "resumeLockCommand": "",
+ "resumeScreenOffCommand": "",
+ "resumeSuspendCommand": "",
+ "screenOffCommand": "",
+ "screenOffTimeout": 280,
+ "suspendCommand": "",
+ "suspendTimeout": 0
+ },
+ "location": {
+ "analogClockInCalendar": false,
+ "autoLocate": false,
+ "firstDayOfWeek": -1,
+ "hideWeatherCityName": true,
+ "hideWeatherTimezone": false,
+ "name": "",
+ "showCalendarEvents": true,
+ "showCalendarWeather": true,
+ "showWeekNumberInCalendar": false,
+ "use12hourFormat": false,
+ "useFahrenheit": false,
+ "weatherEnabled": true,
+ "weatherShowEffects": true,
+ "weatherTaliaMascotAlways": false
+ },
+ "network": {
+ "bluetoothAutoConnect": true,
+ "bluetoothDetailsViewMode": "grid",
+ "bluetoothHideUnnamedDevices": false,
+ "bluetoothRssiPollIntervalMs": 60000,
+ "bluetoothRssiPollingEnabled": false,
+ "disableDiscoverability": false,
+ "networkPanelView": "wifi",
+ "wifiDetailsViewMode": "grid"
+ },
+ "nightLight": {
+ "autoSchedule": true,
+ "dayTemp": "6500",
+ "enabled": false,
+ "forced": false,
+ "manualSunrise": "06:30",
+ "manualSunset": "18:30",
+ "nightTemp": "4000"
+ },
+ "noctaliaPerformance": {
+ "disableDesktopWidgets": false,
+ "disableWallpaper": true
+ },
+ "notifications": {
+ "backgroundOpacity": 0.62,
+ "clearDismissed": true,
+ "criticalUrgencyDuration": 15,
+ "density": "default",
+ "enableBatteryToast": true,
+ "enableKeyboardLayoutToast": true,
+ "enableMarkdown": false,
+ "enableMediaToast": false,
+ "enabled": true,
+ "location": "top_right",
+ "lowUrgencyDuration": 3,
+ "monitors": [
+ ],
+ "normalUrgencyDuration": 8,
+ "overlayLayer": true,
+ "respectExpireTimeout": false,
+ "saveToHistory": {
+ "critical": true,
+ "low": true,
+ "normal": true
+ },
+ "sounds": {
+ "criticalSoundFile": "",
+ "enabled": false,
+ "excludedApps": "discord,firefox,chrome,chromium,edge",
+ "lowSoundFile": "",
+ "normalSoundFile": "",
+ "separateSounds": false,
+ "volume": 0.5
+ }
+ },
+ "osd": {
+ "autoHideMs": 2000,
+ "backgroundOpacity": 1,
+ "enabled": true,
+ "enabledTypes": [
+ 0,
+ 1,
+ 2
+ ],
+ "location": "top_right",
+ "monitors": [
+ ],
+ "overlayLayer": true
+ },
+ "plugins": {
+ "autoUpdate": false,
+ "notifyUpdates": true
+ },
+ "sessionMenu": {
+ "countdownDuration": 5000,
+ "enableCountdown": true,
+ "largeButtonsLayout": "single-row",
+ "largeButtonsStyle": true,
+ "position": "center",
+ "powerOptions": [
+ {
+ "action": "lock",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": true,
+ "keybind": "1"
+ },
+ {
+ "action": "suspend",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": false,
+ "keybind": ""
+ },
+ {
+ "action": "hibernate",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": false,
+ "keybind": ""
+ },
+ {
+ "action": "reboot",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": true,
+ "keybind": "2"
+ },
+ {
+ "action": "logout",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": true,
+ "keybind": "3"
+ },
+ {
+ "action": "shutdown",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": true,
+ "keybind": "4"
+ },
+ {
+ "action": "rebootToUefi",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": false,
+ "keybind": ""
+ },
+ {
+ "action": "userspaceReboot",
+ "command": "",
+ "countdownEnabled": true,
+ "enabled": false,
+ "keybind": ""
+ }
+ ],
+ "showHeader": true,
+ "showKeybinds": true
+ },
+ "settingsVersion": 59,
+ "systemMonitor": {
+ "batteryCriticalThreshold": 5,
+ "batteryWarningThreshold": 20,
+ "cpuCriticalThreshold": 90,
+ "cpuWarningThreshold": 80,
+ "criticalColor": "",
+ "diskAvailCriticalThreshold": 10,
+ "diskAvailWarningThreshold": 20,
+ "diskCriticalThreshold": 90,
+ "diskWarningThreshold": 80,
+ "enableDgpuMonitoring": false,
+ "externalMonitor": "resources || missioncenter || jdsystemmonitor || corestats || system-monitoring-center || gnome-system-monitor || plasma-systemmonitor || mate-system-monitor || ukui-system-monitor || deepin-system-monitor || pantheon-system-monitor",
+ "gpuCriticalThreshold": 90,
+ "gpuWarningThreshold": 80,
+ "memCriticalThreshold": 90,
+ "memWarningThreshold": 80,
+ "swapCriticalThreshold": 90,
+ "swapWarningThreshold": 80,
+ "tempCriticalThreshold": 90,
+ "tempWarningThreshold": 80,
+ "useCustomColors": false,
+ "warningColor": ""
+ },
+ "templates": {
+ "activeTemplates": [
+ {
+ "enabled": true,
+ "id": "zenBrowser"
+ },
+ {
+ "enabled": true,
+ "id": "kitty"
+ },
+ {
+ "enabled": true,
+ "id": "gtk"
+ },
+ {
+ "enabled": true,
+ "id": "btop"
+ },
+ {
+ "enabled": true,
+ "id": "yazi"
+ },
+ {
+ "enabled": true,
+ "id": "niri"
+ },
+ {
+ "enabled": true,
+ "id": "discord"
+ },
+ {
+ "enabled": true,
+ "id": "qt"
+ }
+ ],
+ "enableUserTheming": false
+ },
+ "ui": {
+ "boxBorderEnabled": true,
+ "fontDefault": "EnvyCodeR Nerd Font",
+ "fontDefaultScale": 1,
+ "fontFixed": "EnvyCodeR Nerd Font Mono",
+ "fontFixedScale": 1,
+ "panelBackgroundOpacity": 0.93,
+ "panelsAttachedToBar": true,
+ "scrollbarAlwaysVisible": true,
+ "settingsPanelMode": "attached",
+ "settingsPanelSideBarCardStyle": false,
+ "tooltipsEnabled": true,
+ "translucentWidgets": false
+ },
+ "wallpaper": {
+ "automationEnabled": false,
+ "directory": "/home/dox/Documents/wallpaper",
+ "enableMultiMonitorDirectories": false,
+ "enabled": true,
+ "favorites": [
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "paletteColors": [
+ "#ffb3b0",
+ "#e7bdba",
+ "#e3c28c",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/Wallpaper_74.png",
+ "useWallpaperColors": true
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "rainbow",
+ "paletteColors": [
+ "#9ccaff",
+ "#bac8db",
+ "#d5bee5",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/walls/digital/a_cartoon_of_a_woman_sitting_on_a_plane.png",
+ "useWallpaperColors": true
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "rainbow",
+ "paletteColors": [
+ "#ffb4a6",
+ "#e7bdb5",
+ "#ddc48c",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/gruvbox-wallpapers/wallpapers/photography/garrett-parker-DlkF4-dbCOU.jpg",
+ "useWallpaperColors": true
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "paletteColors": [
+ "#ffb3ae",
+ "#e7bdb9",
+ "#e2c28c",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/gruvbox-wallpapers/wallpapers/pixelart/girl-reading-book.png",
+ "useWallpaperColors": true
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "paletteColors": [
+ "#ffaced",
+ "#dbbed1",
+ "#f5b9a5",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/walls/apocalypse/a_motorcycle_parked_outside_a_restaurant.jpg",
+ "useWallpaperColors": true
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "paletteColors": [
+ "#b58900",
+ "#d33682",
+ "#cb4b16",
+ "#dc322f"
+ ],
+ "path": "/home/dox/Documents/wallpaper/gruvbox-wallpapers/wallpapers/minimalistic/ALLqk82.png",
+ "useWallpaperColors": false
+ },
+ {
+ "appearance": "dark",
+ "colorScheme": "Solarized",
+ "darkMode": true,
+ "generationMethod": "tonal-spot",
+ "paletteColors": [
+ "#fdba4b",
+ "#dcc3a1",
+ "#b6cea3",
+ "#ffb4ab"
+ ],
+ "path": "/home/dox/Documents/wallpaper/gruvbox-wallpapers/wallpapers/anime/light/vagabond.jpg",
+ "useWallpaperColors": true
+ }
+ ],
+ "fillColor": "#000000",
+ "fillMode": "crop",
+ "hideWallpaperFilenames": false,
+ "linkLightAndDarkWallpapers": true,
+ "monitorDirectories": [
+ ],
+ "overviewBlur": 0,
+ "overviewEnabled": true,
+ "overviewTint": 0,
+ "panelPosition": "center",
+ "randomIntervalSec": 660,
+ "setWallpaperOnAllMonitors": true,
+ "showHiddenFiles": false,
+ "skipStartupTransition": false,
+ "solidColor": "#1a1a2e",
+ "sortOrder": "random",
+ "transitionDuration": 1000,
+ "transitionEdgeSmoothness": 0.05,
+ "transitionType": [
+ "fade",
+ "disc",
+ "stripes",
+ "wipe",
+ "pixelate",
+ "honeycomb"
+ ],
+ "useOriginalImages": false,
+ "useSolidColor": false,
+ "useWallhaven": false,
+ "viewMode": "recursive",
+ "wallhavenApiKey": "",
+ "wallhavenCategories": "111",
+ "wallhavenOrder": "desc",
+ "wallhavenPurity": "100",
+ "wallhavenQuery": "",
+ "wallhavenRatios": "",
+ "wallhavenResolutionHeight": "",
+ "wallhavenResolutionMode": "atleast",
+ "wallhavenResolutionWidth": "",
+ "wallhavenSorting": "relevance",
+ "wallpaperChangeMode": "random"
+ }
+}
diff --git a/.config/noctalia/user-templates.toml b/.config/noctalia/user-templates.toml
new file mode 100644
index 0000000..4df3b4e
--- /dev/null
+++ b/.config/noctalia/user-templates.toml
@@ -0,0 +1,17 @@
+[config]
+
+[templates]
+
+# User-defined templates
+# Add your custom templates below
+# Example:
+# [templates.myapp]
+# input_path = "~/.config/noctalia/templates/myapp.css"
+# output_path = "~/.config/myapp/theme.css"
+# post_hook = "myapp --reload-theme"
+
+# Remove this section and add your own templates
+#[templates.placeholder]
+#input_path = "/etc/xdg/quickshell/noctalia-shell/Assets/Templates/noctalia.json"
+#output_path = "/home/dox/.cache/noctalia/placeholder.json"
+
diff --git a/.config/nvim/README.md b/.config/nvim/README.md
old mode 100644
new mode 100755
diff --git a/.config/nvim/init.lua b/.config/nvim/init.lua
old mode 100644
new mode 100755
index 5e8dece..48c95c8
--- a/.config/nvim/init.lua
+++ b/.config/nvim/init.lua
@@ -1,11 +1,23 @@
-require("core.lazy")
+vim.g.mapleader = " "
+vim.g.maplocalleader = " "
+require("core.pack")
require("config.utils")
--- require("custom.colorscheme")
require("core.lsp")
+-- require("custom.colorscheme")
require("core.Mason-path")
require("config.mason-verify")
require("config.keymaps")
require("config.opts")
+require("vim._core.ui2").enable({
+ enable = true,
+ msg = {
+ target = "cmd",
+ pager = { height = 0.5 },
+ dialog = { height = 0.5 },
+ cmd = { height = 0.5 },
+ msg = { height = 0.5, timeout = 4500 },
+ },
+})
require("config.autocmds")
require("config.statusline")
require("custom.startup")
diff --git a/.config/nvim/lazy-lock.json b/.config/nvim/lazy-lock.json
deleted file mode 100644
index 5754011..0000000
--- a/.config/nvim/lazy-lock.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "LuaSnip": { "branch": "master", "commit": "642b0c595e11608b4c18219e93b88d7637af27bc" },
- "bafa.nvim": { "branch": "main", "commit": "7526f5fd61dba671c5fd571fb5351a031caf7b1a" },
- "blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
- "ccc.nvim": { "branch": "main", "commit": "9d1a256e006decc574789dfc7d628ca11644d4c2" },
- "conform.nvim": { "branch": "master", "commit": "086a40dc7ed8242c03be9f47fbcee68699cc2395" },
- "cord.nvim": { "branch": "master", "commit": "283e4a5bdafd0d6187f28e5b739cccab6b4c9c64" },
- "darcubox-nvim": { "branch": "main", "commit": "4945a37ac2ff5a286a4234756710f13c1572d6cc" },
- "diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
- "fff.nvim": { "branch": "main", "commit": "db4cd2825c32e5e53e12bb8af106ff724d33904e" },
- "flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
- "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
- "gitsigns.nvim": { "branch": "main", "commit": "4ed47e8c4c66c921dc1d6643977e0526e1f44396" },
- "gruvbox": { "branch": "main", "commit": "9d0e3af224c5492069a493bb43c6ec2b78497454" },
- "gruvdark.nvim": { "branch": "main", "commit": "94afaee342e8dd0fae054a15402568ec0235c720" },
- "indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
- "kanagawa.nvim": { "branch": "master", "commit": "aef7f5cec0a40dbe7f3304214850c472e2264b10" },
- "koda.nvim": { "branch": "main", "commit": "a560a332ccc1eb2caacb280a390213bb9f37b3cb" },
- "lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
- "mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" },
- "mini.files": { "branch": "main", "commit": "ced297546b8fdb8e215d416d4753a735514a2fe0" },
- "mini.nvim": { "branch": "main", "commit": "402ee6c6ec8ea44b22330446c8fb4e615fd3953e" },
- "neogit": { "branch": "master", "commit": "5a7fca171e3ad07380745d573d791e95268b8f3f" },
- "nvim-lint": { "branch": "master", "commit": "4b03656c09c1561f89b6aa0665c15d292ba9499d" },
- "nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
- "nvim-treesitter-textobjects": { "branch": "main", "commit": "93d60a475f0b08a8eceb99255863977d3a25f310" },
- "nvim-web-devicons": { "branch": "master", "commit": "40e9d5a6cc3db11b309e39593fc7ac03bb844e38" },
- "persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
- "petrolnoir.nvim": { "branch": "master", "commit": "874572bfc75c19a4efc038f5f12f651e04b7d549" },
- "plenary.nvim": { "branch": "master", "commit": "b9fd5226c2f76c951fc8ed5923d85e4de065e509" },
- "screensaver.nvim": { "branch": "main", "commit": "38cf0b8a96b5f4a5e765ce32d43a416441462179" },
- "showkeys": { "branch": "main", "commit": "cb0a50296f11f1e585acffba8c253b9e8afc1f84" },
- "snacks.nvim": { "branch": "main", "commit": "ad9ede6a9cddf16cedbd31b8932d6dcdee9b716e" },
- "sora": { "branch": "main", "commit": "7362774d3d42e0ac0944168c3ce83676c081a178" },
- "supermaven-nvim": { "branch": "main", "commit": "07d20fce48a5629686aefb0a7cd4b25e33947d50" },
- "techbase.nvim": { "branch": "main", "commit": "fc4e039e370043d7ae7203c9a1dbbda76f683740" },
- "todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
- "treesj": { "branch": "main", "commit": "26bc2a8432ba3ea79ed6aa346fba780a3d372570" },
- "trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
- "ts-comments.nvim": { "branch": "main", "commit": "123a9fb12e7229342f807ec9e6de478b1102b041" },
- "twilight.nvim": { "branch": "main", "commit": "664e752f4a219801265cc3fc18782b457b58c1e1" },
- "vague.nvim": { "branch": "main", "commit": "8c4643f8a5106dbc492918f86ff6b5c81111ce9a" },
- "vim-tmux-navigator": { "branch": "master", "commit": "e41c431a0c7b7388ae7ba341f01a0d217eb3a432" },
- "which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" },
- "zen.nvim": { "branch": "main", "commit": "9a60e3fbef521f754cb54a78106647873a50a8ae" }
-}
diff --git a/.config/nvim/lazy-lock.json.bk b/.config/nvim/lazy-lock.json.bk
deleted file mode 100644
index e39047e..0000000
--- a/.config/nvim/lazy-lock.json.bk
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "LuaSnip": { "branch": "master", "commit": "dae4f5aaa3574bd0c2b9dd20fb9542a02c10471c" },
- "ccc.nvim": { "branch": "main", "commit": "9d1a256e006decc574789dfc7d628ca11644d4c2" },
- "conform.nvim": { "branch": "master", "commit": "40dcec5555f960b0a04340d76eabdf4efe78599d" },
- "cord.nvim": { "branch": "master", "commit": "283e4a5bdafd0d6187f28e5b739cccab6b4c9c64" },
- "darcubox-nvim": { "branch": "main", "commit": "4945a37ac2ff5a286a4234756710f13c1572d6cc" },
- "diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
- "flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
- "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
- "gitsigns.nvim": { "branch": "main", "commit": "9f3c6dd7868bcc116e9c1c1929ce063b978fa519" },
- "gruvdark.nvim": { "branch": "main", "commit": "371afe9687fac120d02524942a72b487c1e813b9" },
- "indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
- "kanagawa.nvim": { "branch": "master", "commit": "aef7f5cec0a40dbe7f3304214850c472e2264b10" },
- "lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
- "mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" },
- "mini.files": { "branch": "main", "commit": "57eb96a828f80efb8095a611e3aafcfa43548f8b" },
- "mini.nvim": { "branch": "main", "commit": "0098de999048af0539183d625c52d733318a441b" },
- "neogit": { "branch": "master", "commit": "515e8cb9c3430a064ec3d9affd499b94f71b3120" },
- "nvim-lint": { "branch": "master", "commit": "606b823a57b027502a9ae00978ebf4f5d5158098" },
- "nvim-treesitter": { "branch": "master", "commit": "42fc28ba918343ebfd5565147a42a26580579482" },
- "nvim-treesitter-textobjects": { "branch": "master", "commit": "5ca4aaa6efdcc59be46b95a3e876300cfead05ef" },
- "nvim-web-devicons": { "branch": "master", "commit": "737cf6c657898d0c697311d79d361288a1343d50" },
- "persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
- "petrolnoir.nvim": { "branch": "master", "commit": "874572bfc75c19a4efc038f5f12f651e04b7d549" },
- "plenary.nvim": { "branch": "master", "commit": "b9fd5226c2f76c951fc8ed5923d85e4de065e509" },
- "screensaver.nvim": { "branch": "main", "commit": "38cf0b8a96b5f4a5e765ce32d43a416441462179" },
- "showkeys": { "branch": "main", "commit": "cb0a50296f11f1e585acffba8c253b9e8afc1f84" },
- "snacks.nvim": { "branch": "main", "commit": "9912042fc8bca2209105526ac7534e9a0c2071b2" },
- "supermaven-nvim": { "branch": "main", "commit": "07d20fce48a5629686aefb0a7cd4b25e33947d50" },
- "techbase.nvim": { "branch": "main", "commit": "fc4e039e370043d7ae7203c9a1dbbda76f683740" },
- "todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
- "treesj": { "branch": "main", "commit": "186084dee5e9c8eec40f6e39481c723dd567cb05" },
- "trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
- "ts-comments.nvim": { "branch": "main", "commit": "123a9fb12e7229342f807ec9e6de478b1102b041" },
- "twilight.nvim": { "branch": "main", "commit": "664e752f4a219801265cc3fc18782b457b58c1e1" },
- "vague.nvim": { "branch": "main", "commit": "4abd79d6fbaca57bf437763d0b5bed364e4512bd" },
- "vim-tmux-navigator": { "branch": "master", "commit": "e41c431a0c7b7388ae7ba341f01a0d217eb3a432" },
- "which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" },
- "zen.nvim": { "branch": "main", "commit": "9a60e3fbef521f754cb54a78106647873a50a8ae" }
-}
diff --git a/.config/nvim/lsp/astro.lua b/.config/nvim/lsp/astro.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/bashls.lua b/.config/nvim/lsp/bashls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/biome.lua b/.config/nvim/lsp/biome.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/cssls.lua b/.config/nvim/lsp/cssls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/docker_compose_language_service.lua b/.config/nvim/lsp/docker_compose_language_service.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/docker_language_server.lua b/.config/nvim/lsp/docker_language_server.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/dockerls.lua b/.config/nvim/lsp/dockerls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/eslint.lua b/.config/nvim/lsp/eslint.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/golangci_lint_ls.lua b/.config/nvim/lsp/golangci_lint_ls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/gopls.lua b/.config/nvim/lsp/gopls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/html.lua b/.config/nvim/lsp/html.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/lua_ls.lua b/.config/nvim/lsp/lua_ls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/marksman.lua b/.config/nvim/lsp/marksman.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/rust_analyzer.lua b/.config/nvim/lsp/rust_analyzer.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/stylua3p_ls.lua b/.config/nvim/lsp/stylua3p_ls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/tailwindcss.lua b/.config/nvim/lsp/tailwindcss.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/ts_ls.lua b/.config/nvim/lsp/ts_ls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/tsgo.lua b/.config/nvim/lsp/tsgo.lua
new file mode 100644
index 0000000..321779c
--- /dev/null
+++ b/.config/nvim/lsp/tsgo.lua
@@ -0,0 +1,169 @@
+---@brief
+---
+--- https://github.com/microsoft/typescript-go
+---
+--- `typescript-go` is experimental port of the TypeScript compiler (tsc) and language server (tsserver) to the Go programming language.
+---
+--- `tsgo` can be installed via npm `npm install @typescript/native-preview`.
+---
+--- ### Monorepo support
+---
+--- `tsgo` supports monorepos by default. It will automatically find the `tsconfig.json` or `jsconfig.json` corresponding to the package you are working on.
+--- This works without the need of spawning multiple instances of `tsgo`, saving memory.
+---
+--- It is recommended to use the same version of TypeScript in all packages, and therefore have it available in your workspace root. The location of the TypeScript binary will be determined automatically, but only once.
+---
+--- Some care must be taken here to correctly infer whether a file is part of a Deno program, or a TS program that
+--- expects to run in Node or Web Browsers. This supports having a Deno module using the denols LSP as a part of a
+--- mostly-not-Deno monorepo. We do this by finding the nearest package manager lock file, and the nearest deno.json
+--- or deno.jsonc.
+---
+--- Example:
+---
+--- ```
+--- project-root
+--- +-- node_modules/...
+--- +-- package-lock.json
+--- +-- package.json
+--- +-- packages
+--- +-- deno-module
+--- | +-- deno.json
+--- | +-- package.json <-- It's normal for Deno projects to have package.json files!
+--- | +-- src
+--- | +-- index.ts <-- this is a Deno file
+--- +-- node-module
+--- +-- package.json
+--- +-- src
+--- +-- index.ts <-- a non-Deno file (ie, should use ts_ls or tsgols)
+--- ```
+---
+--- From the file being edited, we walk up to find the nearest package manager lockfile. This is PROJECT ROOT.
+--- From the file being edited, find the nearest deno.json or deno.jsonc. This is DENO ROOT.
+--- From the file being edited, find the nearest deno.lock. This is DENO LOCK ROOT
+--- If DENO LOCK ROOT is found, and PROJECT ROOT is missing or shorter, then this is a deno file, and we abort.
+--- If DENO ROOT is found, and it's longer than or equal to PROJECT ROOT, then this is a Deno file, and we abort.
+--- Otherwise, attach at PROJECT ROOT, or the cwd if not found.
+
+---@type vim.lsp.Config
+return {
+ init_options = {
+ hostInfo = "neovim",
+ preferences = {
+ includeInlayParameterNameHints = "all",
+ includeInlayParameterNameHintsWhenArgumentMatchesName = false,
+ includeInlayFunctionParameterTypeHints = true,
+ includeInlayVariableTypeHints = true,
+ includeInlayPropertyDeclarationTypeHints = true,
+ includeInlayFunctionLikeReturnTypeHints = true,
+ includeInlayEnumMemberValueHints = true,
+ },
+ },
+ settings = {
+ typescript = {
+ inlayHints = {
+ parameterNames = {
+ enabled = "literals",
+ suppressWhenArgumentMatchesName = true,
+ },
+ parameterTypes = { enabled = true },
+ variableTypes = { enabled = false },
+ propertyDeclarationTypes = { enabled = true },
+ functionLikeReturnTypes = { enabled = true },
+ enumMemberValues = { enabled = true },
+ },
+ suggest = { completeFunctionCalls = true },
+ updateImportsOnFileMove = { enabled = "always" },
+ },
+ javascript = {
+ inlayHints = {
+ parameterNames = {
+ enabled = "literals",
+ suppressWhenArgumentMatchesName = true,
+ },
+ parameterTypes = { enabled = true },
+ variableTypes = { enabled = false },
+ propertyDeclarationTypes = { enabled = true },
+ functionLikeReturnTypes = { enabled = true },
+ enumMemberValues = { enabled = true },
+ },
+ },
+ },
+ cmd = function(dispatchers, config)
+ local cmd = "tsgo"
+ if (config or {}).root_dir then
+ local local_cmd = vim.fs.joinpath(config.root_dir, "node_modules/.bin", cmd)
+ if vim.fn.executable(local_cmd) == 1 then
+ cmd = local_cmd
+ end
+ end
+ return vim.lsp.rpc.start({ cmd, "--lsp", "--stdio" }, dispatchers)
+ end,
+ filetypes = {
+ "javascript",
+ "javascriptreact",
+ "javascript.jsx",
+ "typescript",
+ "typescriptreact",
+ "typescript.tsx",
+ },
+ root_dir = function(bufnr, on_dir)
+ -- The project root is where the LSP can be started from
+ -- As stated in the documentation above, this LSP supports monorepos and simple projects.
+ -- We select then from the project root, which is identified by the presence of a package
+ -- manager lock file.
+ local root_markers = { "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock" }
+ -- Give the root markers equal priority by wrapping them in a table
+ root_markers = vim.fn.has("nvim-0.11.3") == 1 and { root_markers, { ".git" } }
+ or vim.list_extend(root_markers, { ".git" })
+
+ local deno_root = vim.fs.root(bufnr, { "deno.json", "deno.jsonc" })
+ local deno_lock_root = vim.fs.root(bufnr, { "deno.lock" })
+ local project_root = vim.fs.root(bufnr, root_markers)
+ if deno_lock_root and (not project_root or #deno_lock_root > #project_root) then
+ -- deno lock is closer than package manager lock, abort
+ return
+ end
+ if deno_root and (not project_root or #deno_root >= #project_root) then
+ -- deno config is closer than or equal to package manager lock, abort
+ return
+ end
+ -- project is standard TS, not deno
+ -- We fallback to the current working directory if no project root is found
+ on_dir(project_root or vim.fn.getcwd())
+ end,
+ commands = {
+ ["editor.action.showReferences"] = function(command, ctx)
+ local client = assert(vim.lsp.get_client_by_id(ctx.client_id))
+ local file_uri, position, references = unpack(command.arguments)
+ local quickfix_items = vim.lsp.util.locations_to_items(references, client.offset_encoding)
+ vim.fn.setqflist({}, " ", {
+ title = command.title,
+ items = quickfix_items,
+ context = {
+ command = command,
+ bufnr = ctx.bufnr,
+ },
+ })
+ vim.lsp.util.show_document({
+ uri = file_uri,
+ range = {
+ start = position,
+ ["end"] = position,
+ },
+ }, client.offset_encoding)
+ vim.cmd("botright copen")
+ end,
+ },
+ on_attach = function(client, bufnr)
+ vim.api.nvim_buf_create_user_command(bufnr, "LspTypescriptSourceAction", function()
+ local source_actions = vim.tbl_filter(function(action)
+ return vim.startswith(action, "source.")
+ end, client.server_capabilities.codeActionProvider.codeActionKinds)
+ vim.lsp.buf.code_action({
+ context = {
+ only = source_actions,
+ },
+ })
+ end, {})
+ end,
+}
diff --git a/.config/nvim/lsp/vtsls.lua b/.config/nvim/lsp/vtsls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lsp/vue_ls.lua b/.config/nvim/lsp/vue_ls.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/config/autocmds.lua b/.config/nvim/lua/config/autocmds.lua
old mode 100644
new mode 100755
index a2e57bc..bfe1995
--- a/.config/nvim/lua/config/autocmds.lua
+++ b/.config/nvim/lua/config/autocmds.lua
@@ -215,3 +215,25 @@ api.nvim_create_autocmd("LspAttach", {
end
end,
})
+
+-- Pack Delete and Update cmds are built-in on Nightly 0.13
+vim.api.nvim_create_user_command("PackDel", function(opts)
+ vim.pack.del(opts.fargs)
+end, { nargs = "+", desc = "Delete plugins (:PackDel plugin1 plugin2)" })
+
+vim.api.nvim_create_user_command("PackAdd", function(opts)
+ vim.pack.add(opts.fargs)
+end, { nargs = "+", desc = "Add plugins (:PackAdd user/repo1 user/repo2)" })
+
+vim.api.nvim_create_user_command("PackUpdate", function(opts)
+ -- checks if any argument is passed
+ if opts.args:match("%S") then
+ -- update specific plugins
+ local plugins = vim.split(opts.args, "%s+", { trimempty = true })
+ -- update only specified plugins
+ vim.pack.update(plugins)
+ else
+ -- update all
+ vim.pack.update()
+ end
+end, { nargs = "*", desc = "Update all plugins or specific ones" })
diff --git a/.config/nvim/lua/config/keymaps.lua b/.config/nvim/lua/config/keymaps.lua
old mode 100644
new mode 100755
index ad11df3..933c03e
--- a/.config/nvim/lua/config/keymaps.lua
+++ b/.config/nvim/lua/config/keymaps.lua
@@ -4,7 +4,12 @@ map({ "n", "x" }, "j", "v:count == 0 ? 'gj' : 'j'", { desc = "Down", expr = true
map({ "n", "x" }, "", "v:count == 0 ? 'gj' : 'j'", { desc = "Down", expr = true, silent = true })
map({ "n", "x" }, "k", "v:count == 0 ? 'gk' : 'k'", { desc = "Up", expr = true, silent = true })
map({ "n", "x" }, "", "v:count == 0 ? 'gk' : 'k'", { desc = "Up", expr = true, silent = true })
-map("n", "u", "Undotree", { desc = "Undotree" })
+map("n", "tu", "Undotree", { desc = "Undotree" })
+map("n", "", "zz") -- Scroll down and center the cursor
+map("n", "", "zz") -- Scroll up and center the cursor
+-- Goto
+map("n", "gl", "$", { desc = "Go to end of line" })
+map("n", "gh", "^", { desc = "Go to start of line" })
-- splits
map("n", "-", "s", { desc = "Split Window Below", remap = true })
map("n", "|", "v", { desc = "Split Window Right", remap = true })
@@ -24,11 +29,8 @@ map("v", "<", "", ">gv")
map("n", "nr", ":restart", { desc = "restart nvim" })
map("i", "jk", "", { noremap = false })
-map("n", "L", ":Lazy", { desc = "Lazy" })
-map("n", "bs", function()
- require("snipe").open_buffer_menu()
-end, { desc = "Open Snipe buffer menu" })
map("n", "qq", "qa", { desc = "Quit All" })
+map("n", "pu", "lua vim.pack.update()", { desc = "Update Plugins" })
-- tmux navigate
map("n", "", "TmuxNavigateDown")
map("n", "", "TmuxNavigateLeft")
@@ -39,23 +41,26 @@ map("n", "cv", function()
vim.diagnostic.config({ virtual_lines = new_config })
end, { desc = "Toggle diagnostic virtual_lines" })
-- snapcode
-map("x", "cp", "CodeSnap", { desc = "Save selected code snapshot into clipboard" })
-map("x", "cP", "CodeSnapASCII", { desc = "make ascii snapshot" })
map("n", "uD", "Twilight", { desc = "toggle diming" })
-- buffer
map("n", "bb", "e #", { desc = "Switch to Other Buffer" })
-map("n", "[b", "bprevious", { desc = "Prev Buffer" })
+-- map("n", "bp", "bprevious", { desc = "Prev Buffer" })
map("n", "bd", function()
Snacks.bufdelete()
end, { desc = "Delete Buffer" })
map("n", "bo", function()
Snacks.bufdelete.other()
end, { desc = "Delete Other Buffers" })
-map("n", "cp", "CccPick", { desc = "Code color Picker" })
+map("n", "cp", function()
+ vim.cmd("CccPick")
+end, { desc = "Code color Picker" })
map("n", ",", function()
require("bafa").toggle()
end, { desc = "Buffers" })
-
+map("n", "R", function()
+ require("persistence").save()
+ vim.cmd("restart source " .. vim.fn.fnameescape(require("persistence").current()))
+end, { desc = "Restart Neovim" })
------------------- Snacks -------------------------
-- map("n", ",", function()
-- Snacks.picker.buffers()
@@ -168,7 +173,9 @@ map("n", "gf", function()
end, { desc = "Git Log File" })
map("n", "gB", "Gitsigns blame", { desc = "preview_blame" })
map("n", "gh", "Gitsigns preview_hunk", { desc = "preview_hunk" })
-map("n", "gg", "Neogit", { desc = "Neogit" })
+map("n", "gg", function()
+ vim.cmd("Neogit")
+end, { desc = "Neogit" })
-- files
map("n", "", function()
require("fff").live_grep({ grep = { modes = { "fuzzy", "plain" } } })
diff --git a/.config/nvim/lua/config/mason-verify.lua b/.config/nvim/lua/config/mason-verify.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/config/opts.lua b/.config/nvim/lua/config/opts.lua
old mode 100644
new mode 100755
index 59554cd..465ec07
--- a/.config/nvim/lua/config/opts.lua
+++ b/.config/nvim/lua/config/opts.lua
@@ -3,9 +3,7 @@ local o = vim.opt
local undodir = vim.fn.stdpath("state") .. "/undo"
vim.fn.mkdir(undodir, "p")
--- â•─────────────────────────────╮
-- │ General │
--- ╰─────────────────────────────╯
vim.g.loaded_netrw = 1 -- Disable netrw file explorer
vim.g.loaded_netrwPlugin = 1
vim.g.mapleader = " " -- Set key to Space
@@ -13,9 +11,8 @@ vim.g.maplocalleader = " " -- Set key to Space
-- vim.g.snacks_animate = true
vim.g.root_spec = { "lsp", { ".git", "lua" }, "cwd" }
-- vim.g.trouble_lualine = true
--- â•─────────────────────────────╮
+
-- │ Files & Backups │
--- ╰─────────────────────────────╯
o.undofile = true -- Enable persistent undo
o.undodir = undodir -- Set undo directory
o.undolevels = 1000 -- Max undo steps
@@ -25,9 +22,7 @@ o.writebackup = false -- Disable write backup
o.swapfile = false -- Optional: disable swap files
o.autowrite = true -- Auto save before commands like :next, :make
--- â•─────────────────────────────╮
-- │ Search │
--- ╰─────────────────────────────╯
o.ignorecase = true -- Case insensitive search...
o.smartcase = true -- ...unless capital letter in query
o.incsearch = true -- Incremental search
@@ -35,9 +30,8 @@ o.hlsearch = false -- Highlight matches
o.inccommand = "nosplit" -- Show live preview of :s command
o.grepformat = "%f:%l:%c:%m"
o.grepprg = "rg --vimgrep"
--- â•─────────────────────────────╮
+
-- │ UI │
--- ╰─────────────────────────────╯
o.foldmethod = "expr"
o.foldexpr = "nvim_treesitter#foldexpr()"
o.foldenable = true
@@ -55,7 +49,7 @@ o.title = true -- Set terminal title
o.confirm = true -- Confirm before closing modified buffer
o.fillchars = { eob = " " } -- Hide ~ at end of buffer
o.guifont = "monospace:h17" -- Font for GUI clients
-o.guicursor = ""
+o.guicursor = "n-v-c:block,i:block,r-cr:block,o:block"
o.winborder = "rounded" -- Window border style
o.laststatus = 3 -- global statusline
o.splitbelow = true -- Horizontal splits open below
@@ -68,26 +62,23 @@ o.shortmess:append({ W = true, I = true, c = true, C = true })
-- o.pumblend = 10
o.list = true
o.linebreak = true
--- â•─────────────────────────────╮
+
-- │ Tabs & Indentation │
--- ╰─────────────────────────────╯
o.expandtab = true -- Convert tabs to spaces
o.tabstop = 2 -- Spaces per tab
o.softtabstop = 2 -- Spaces per
o.shiftwidth = 2 -- Spaces per indent
o.smartindent = true -- Smart auto-indenting
o.sessionoptions = { "buffers", "curdir", "tabpages", "winsize", "help", "globals", "skiprtp", "folds" }
--- â•─────────────────────────────╮
+
-- │ Line Numbers & Layout │
--- ╰─────────────────────────────╯
o.number = true -- Show absolute line numbers
o.relativenumber = true -- Show relative line numbers
o.cursorline = true -- Highlight current line
o.breakindent = true -- Indent wrapped lines
o.wrap = false -- Disable line wrap
o.clipboard = vim.env.SSH_TTY and "" or "unnamedplus" -- Sync with system clipboard
--- â•─────────────────────────────╮
+
-- │ Completion & Conceal │
--- ╰─────────────────────────────╯
o.completeopt = { "menu", "menuone", "noselect" } -- Completion behavior
o.conceallevel = 0 -- Show `` in markdown files
diff --git a/.config/nvim/lua/config/statusline.lua b/.config/nvim/lua/config/statusline.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/config/utils.lua b/.config/nvim/lua/config/utils.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/core/Mason-path.lua b/.config/nvim/lua/core/Mason-path.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/core/lazy.lua b/.config/nvim/lua/core/lazy.lua
deleted file mode 100644
index 4d30515..0000000
--- a/.config/nvim/lua/core/lazy.lua
+++ /dev/null
@@ -1,157 +0,0 @@
--- local lazierPath = vim.fn.stdpath("data") .. "/lazier/lazier.nvim"
--- if not (vim.uv or vim.loop).fs_stat(lazierPath) then
--- local repo = "https://github.com/jake-stewart/lazier.nvim.git"
--- local out = vim.fn.system({
--- "git",
--- "clone",
--- "--branch=stable-v2",
--- repo,
--- lazierPath,
--- })
--- if vim.v.shell_error ~= 0 then
--- vim.api.nvim_echo({ {
--- "Failed to clone lazier.nvim:\n" .. out,
--- "Error",
--- } }, true, {})
--- end
--- end
--- vim.opt.runtimepath:prepend(lazierPath)
---
--- require("lazier").setup("plugins", {
--- lazier = {
--- before = function()
--- -- function to run before the ui renders.
--- -- it is faster to require parts of your config here
--- -- since at this point they will be bundled and bytecode compiled.
--- -- eg: require("options")
--- end,
---
--- after = function()
--- -- function to run after the ui renders.
--- -- eg: require("mappings")
--- end,
---
--- start_lazily = function()
--- -- function which returns whether lazy.nvim
--- -- should start delayed or not.
--- local nonLazyLoadableExtensions = {
--- zip = true,
--- tar = true,
--- gz = true,
--- }
--- local fname = vim.fn.expand("%")
--- return fname == ""
--- or vim.fn.isdirectory(fname) == 0
--- and not nonLazyLoadableExtensions[vim.fn.fnamemodify(fname, ":e")]
--- end,
---
--- -- whether plugins should be included in the bytecode
--- -- compiled bundle. this will make your startup slower.
--- bundle_plugins = false,
---
--- -- whether to automatically generate lazy loading config
--- -- by identifying the mappings set when the plugin loads
--- generate_lazy_mappings = true,
---
--- -- automatically rebundle and compile nvim config when it changes
--- -- if set to false then you will need to :LazierClear manually
--- detect_changes = true,
--- },
---
--- -- your usual lazy.nvim config goes here
--- spec = {
--- -- import your plugins
--- { import = "plugins" },
--- --
--- },
--- defaults = {
--- -- smmflkmflm
--- -- If you know what you're doing, you can set this to `true` to have all your custom plugins lazy-loaded by default.
--- lazy = false,
--- },
--- -- Configure any other settings here. See the documentation for more details.
--- -- colorscheme that will be used when installing plugins.
--- install = { colorscheme = { "solarized" } },
--- ui = {
--- border = "rounded",
--- backdrop = 100,
--- title = "dullx Lazy",
--- },
--- -- automatically check for plugin updates
--- checker = {
--- enabled = true,
--- notify = true,
--- },
--- performance = {
--- rtp = {
--- disabled_plugins = {
--- "gzip",
--- "tarPlugin",
--- "tohtml",
--- "tutor",
--- "zipPlugin",
--- },
--- },
--- },
--- -- ...
--- })
--- Bootstrap lazy.nvim
-local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
-if not (vim.uv or vim.loop).fs_stat(lazypath) then
- local lazyrepo = "https://github.com/folke/lazy.nvim.git"
- local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
- if vim.v.shell_error ~= 0 then
- vim.api.nvim_echo({
- { "Failed to clone lazy.nvim:\n", "ErrorMsg" },
- { out, "WarningMsg" },
- { "\nPress any key to exit..." },
- }, true, {})
- vim.fn.getchar()
- os.exit(1)
- end
-end
-vim.opt.rtp:prepend(lazypath)
-
--- Make sure to setup `mapleader` and `maplocalleader` before
--- loading lazy.nvim so that mappings are correct.
--- This is also a good place to setup other settings (vim.opt)
-vim.g.mapleader = " " -- Set key to Space
-vim.g.maplocalleader = " " -- Set key to Space
-
--- Setup lazy.nvim
-require("lazy").setup({
- spec = {
- -- import your plugins
- { import = "plugins" },
- --
- },
- defaults = {
- -- smmflkmflm
- -- If you know what you're doing, you can set this to `true` to have all your custom plugins lazy-loaded by default.
- lazy = false,
- },
- -- Configure any other settings here. See the documentation for more details.
- -- colorscheme that will be used when installing plugins.
- install = { colorscheme = { "solarized" } },
- ui = {
- border = "rounded",
- backdrop = 100,
- title = "dullx Lazy",
- },
- -- automatically check for plugin updates
- checker = {
- enabled = true,
- notify = true,
- },
- performance = {
- rtp = {
- disabled_plugins = {
- "gzip",
- "tarPlugin",
- "tohtml",
- "tutor",
- "zipPlugin",
- },
- },
- },
-})
diff --git a/.config/nvim/lua/core/lsp.lua b/.config/nvim/lua/core/lsp.lua
old mode 100644
new mode 100755
index 022a5bc..e85a0fd
--- a/.config/nvim/lua/core/lsp.lua
+++ b/.config/nvim/lua/core/lsp.lua
@@ -1,4 +1,5 @@
-local capabilities = require("blink.cmp").get_lsp_capabilities()
+local ok, blink = pcall(require, "blink.cmp")
+local capabilities = ok and blink.get_lsp_capabilities() or {}
capabilities = vim.tbl_deep_extend("force", capabilities, {
workspace = {
fileOperations = {
@@ -11,10 +12,10 @@ capabilities = vim.tbl_deep_extend("force", capabilities, {
local ft_to_lsp = {
lua = "lua_ls",
go = "gopls",
- typescript = "ts_ls",
- typescriptreact = "ts_ls",
- javascript = "ts_ls",
- javascriptreact = "ts_ls",
+ typescript = "tsgo",
+ typescriptreact = "tsgo",
+ javascript = "tsgo",
+ javascriptreact = "tsgo",
astro = "astro",
html = "html",
css = "cssls",
@@ -83,7 +84,7 @@ local function restart_lsp(bufnr)
local clients = vim.lsp.get_clients({ bufnr = bufnr })
for _, client in ipairs(clients) do
- vim.lsp.stop_client(client.id)
+ client:stop()
end
vim.defer_fn(function()
@@ -219,7 +220,7 @@ local function lsp_info()
print(" LSP INFORMATION ")
print("â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•\n")
- print("󰈙 Log file: " .. vim.lsp.get_log_path())
+ print("󰈙 Log file: " .. vim.lsp.log.get_filename())
print("󰈔 Filetype: " .. vim.bo.filetype)
print("󰈮 Buffer: " .. bufnr)
print("󰈔 CWD: " .. (vim.fn.getcwd() or "N/A") .. "\n")
@@ -535,9 +536,9 @@ do
},
-- spinner_frames = { 'â ‹', 'â ™', 'â ¹', 'â ¸', 'â ¼', 'â ´', 'â ¦', 'â §', 'â ‡', 'â ' },
-- spinner_frames = { "â ‹", "â ™", "â ¹", "â ¸", "â ¼", "â ´", "â ¦", "â §", "â ‡", "â " },
- -- spinner_frames = { "â—", "â—“", "â—‘", "â—’" },
+ spinner_frames = { "â—", "â—“", "â—‘", "â—’" },
-- spinner_frames = { "â—°", "â—³", "â—²", "â—±" },
- spinner_frames = { "â–±", "â–°", "â–°â–±", "â–°â–°" },
+ -- spinner_frames = { "â–±", "â–°", "â–°â–±", "â–°â–°" },
-- spinner_frames = { "â—‹", "â¬", "â—", "⬮" },
-- spinner_frames = { "â—‹ ", " â—‹ ", " â—‹ ", " â—‹", " â—‹ ", " â—‹ " },
-- spinner_frames = {
diff --git a/.config/nvim/lua/core/pack.lua b/.config/nvim/lua/core/pack.lua
new file mode 100755
index 0000000..8f87258
--- /dev/null
+++ b/.config/nvim/lua/core/pack.lua
@@ -0,0 +1,77 @@
+local M = {}
+
+local pack_root = vim.fn.stdpath("data") .. "/site/pack/core/opt"
+
+local build_hooks = {
+ ["nvim-treesitter"] = ":TSUpdate",
+ ["mason.nvim"] = ":MasonUpdate",
+ ["blink.cmp"] = "cargo build --release",
+ ["cord.nvim"] = ":Cord update",
+ ["fff.nvim"] = "cargo build --release"
+}
+
+local function run_shell_build(name, cmd)
+ local cwd = pack_root .. "/" .. name
+ if vim.fn.isdirectory(cwd) == 0 then
+ vim.notify("Pack build path missing for " .. name, vim.log.levels.WARN)
+ return
+ end
+
+ local result = vim.system({ "sh", "-c", cmd }, { cwd = cwd, text = true }):wait()
+ if result.code ~= 0 then
+ local stderr = result.stderr and result.stderr:gsub("%s+$", "") or ""
+ vim.notify("Pack build failed for " .. name .. ": " .. stderr, vim.log.levels.ERROR)
+ end
+end
+
+local function run_build(name, build)
+ if type(build) == "function" then
+ build()
+ return
+ end
+
+ if type(build) == "string" then
+ if build:sub(1, 1) == ":" then
+ vim.cmd(build)
+ else
+ run_shell_build(name, build)
+ end
+ end
+end
+
+vim.api.nvim_create_autocmd("PackChanged", {
+ callback = function(ev)
+ local kind = ev.data.kind
+ if kind ~= "install" and kind ~= "update" then
+ return
+ end
+
+ local name = ev.data.spec and ev.data.spec.name
+ local build = name and build_hooks[name] or nil
+ if not build then
+ return
+ end
+
+ if not ev.data.active then
+ vim.cmd.packadd(name)
+ end
+ run_build(name, build)
+ end,
+})
+
+vim.schedule(function()
+ vim.api.nvim_exec_autocmds("User", { pattern = "VeryLazy" })
+end)
+
+function M.add(specs, opts)
+ opts = opts or {}
+ if opts.confirm == nil then
+ opts.confirm = false
+ end
+ if opts.load == nil then
+ opts.load = true
+ end
+ vim.pack.add(specs, opts)
+end
+
+return M
diff --git a/.config/nvim/lua/custom/colorscheme.lua b/.config/nvim/lua/custom/colorscheme.lua
old mode 100644
new mode 100755
diff --git a/.config/nvim/lua/custom/startup.lua b/.config/nvim/lua/custom/startup.lua
old mode 100644
new mode 100755
index 0add925..6ce63b9
--- a/.config/nvim/lua/custom/startup.lua
+++ b/.config/nvim/lua/custom/startup.lua
@@ -64,10 +64,38 @@ local function dismiss_intro()
end
intro_buf = nil
- api.nvim_win_set_option(win, "colorcolumn", "100")
- api.nvim_win_set_option(win, "relativenumber", true)
- api.nvim_win_set_option(win, "number", true)
- api.nvim_win_set_option(win, "list", true)
+ api.nvim_set_option_value("colorcolumn", "100", { win = win })
+ api.nvim_set_option_value("relativenumber", true, { win = win })
+ api.nvim_set_option_value("number", true, { win = win })
+ api.nvim_set_option_value("list", true, { win = win })
+end
+
+local function get_pack_plugin_count()
+ local lock_path = vim.fn.stdpath("config") .. "/nvim-pack-lock.json"
+ local fd = vim.loop.fs_open(lock_path, "r", 438)
+ if not fd then
+ return nil
+ end
+ local stat = vim.loop.fs_fstat(fd)
+ if not stat or stat.size == 0 then
+ vim.loop.fs_close(fd)
+ return nil
+ end
+ local data = vim.loop.fs_read(fd, stat.size, 0)
+ vim.loop.fs_close(fd)
+ if not data or data == "" then
+ return nil
+ end
+ local ok, decoded = pcall(vim.json.decode, data)
+ if not ok or type(decoded) ~= "table" then
+ return nil
+ end
+ local plugins = decoded.plugins or {}
+ local count = 0
+ for _ in pairs(plugins) do
+ count = count + 1
+ end
+ return count
end
local function get_stats()
@@ -75,22 +103,17 @@ local function get_stats()
return cached_stats
end
- local ok, lazy = pcall(require, "lazy")
- if not ok then
+ local count = get_pack_plugin_count()
+ if not count or count == 0 then
return "âš¡ Loading..."
end
- local stats = lazy.stats()
- if not stats or stats.loaded == 0 then
- return "âš¡ Loading..."
+ local startup_ms = math.floor((vim.loop.hrtime() - start_time) / 1000000)
+ if startup_ms < 0 then
+ startup_ms = 0
end
- local startup_ms = stats.startuptime
- if not startup_ms or startup_ms == 0 then
- startup_ms = math.floor((vim.loop.hrtime() - start_time) / 1000000)
- end
-
- cached_stats = "âš¡ " .. stats.loaded .. " plugins in " .. math.floor(startup_ms) .. "ms"
+ cached_stats = "âš¡ " .. count .. " plugins in " .. startup_ms .. "ms"
return cached_stats
end
@@ -99,8 +122,8 @@ local function set_ascii_bg()
return
end
- local height = api.nvim_get_option("lines")
- local width = api.nvim_get_option("columns")
+ local height = vim.o.lines
+ local width = vim.o.columns
local ascii = {
[[_, _ _ ,_]],
@@ -173,15 +196,15 @@ local function set_ascii_bg()
api.nvim_buf_set_lines(buf, 0, -1, false, ascii)
vim.cmd("redraw")
- api.nvim_buf_set_option(buf, "modified", false)
- api.nvim_buf_set_option(buf, "buflisted", false)
- api.nvim_buf_set_option(buf, "bufhidden", "wipe")
- api.nvim_buf_set_option(buf, "buftype", "nofile")
- api.nvim_buf_set_option(buf, "swapfile", false)
- api.nvim_win_set_option(win, "colorcolumn", "")
- api.nvim_win_set_option(win, "relativenumber", false)
- api.nvim_win_set_option(win, "number", false)
- api.nvim_win_set_option(win, "list", false)
+ api.nvim_set_option_value("modified", false, { buf = buf })
+ api.nvim_set_option_value("buflisted", false, { buf = buf })
+ api.nvim_set_option_value("bufhidden", "wipe", { buf = buf })
+ api.nvim_set_option_value("buftype", "nofile", { buf = buf })
+ api.nvim_set_option_value("swapfile", false, { buf = buf })
+ api.nvim_set_option_value("colorcolumn", "", { win = win })
+ api.nvim_set_option_value("relativenumber", false, { win = win })
+ api.nvim_set_option_value("number", false, { win = win })
+ api.nvim_set_option_value("list", false, { win = win })
vim.keymap.set("n", "f", function()
require("fff").find_files({
@@ -200,8 +223,7 @@ local function set_ascii_bg()
end, { buffer = buf })
vim.keymap.set("n", "r", function()
- dismiss_intro()
- Snacks.picker.oldfiles()
+ Snacks.picker.recent()
end, { buffer = buf })
vim.keymap.set("n", "c", function()
@@ -211,7 +233,7 @@ local function set_ascii_bg()
vim.keymap.set("n", "o", function()
dismiss_intro()
- require("persistence").load({ last = true })
+ require("persistence").load()
end, { buffer = buf })
vim.keymap.set("n", "h", function()
@@ -275,9 +297,9 @@ vim.api.nvim_create_autocmd("VimEnter", {
end,
})
-vim.api.nvim_create_autocmd("User", {
- pattern = "LazyDone",
+vim.api.nvim_create_autocmd("PackChanged", {
callback = function()
+ cached_stats = nil
if intro_active then
vim.defer_fn(function()
if intro_active then
diff --git a/.config/nvim/lua/plugins/Git.lua b/.config/nvim/lua/plugins/Git.lua
deleted file mode 100644
index 143ac33..0000000
--- a/.config/nvim/lua/plugins/Git.lua
+++ /dev/null
@@ -1,59 +0,0 @@
--- Adds git related signs to the gutter, as well as utilities for managing changes
-return {
- {
- "lewis6991/gitsigns.nvim",
- lazy = false,
- config = function()
- require("gitsigns").setup({
- signs = {
- add = { text = "ï§" },
- change = { text = "|" },
- delete = { text = "ï¨" },
- topdelete = { text = "ó°†´" },
- changedelete = { text = "ó°µ" },
- untracked = { text = "î«" },
- },
- signs_staged = {
- add = { text = "ï•" },
- change = { text = "ï‘™" },
- delete = { text = "ï–" },
- topdelete = { text = "" },
- changedelete = { text = "" },
- untracked = { text = "î«" },
- },
- })
- end,
- },
- {
- "sindrets/diffview.nvim",
- event = "VeryLazy",
- opts = {
- keymaps = {
- disable_defaults = false, -- Disable the default keymaps
- view = {
- { "n", "q", "tabclose", { desc = "close diffview" } },
- },
- },
- },
- dependencies = {
- { "nvim-tree/nvim-web-devicons", lazy = true },
- },
- -- to get it working wiith lazyvim
- },
- {
- "NeogitOrg/neogit",
- event = "VeryLazy",
- opts = {
- graph_style = "kitty",
- },
- dependencies = {
- "nvim-lua/plenary.nvim", -- required
-
- -- Only one of these is needed.
- -- "nvim-telescope/telescope.nvim", -- optional
- -- "ibhagwan/fzf-lua", -- optional
- -- "echasnovski/mini.pick", -- optional
- "folke/snacks.nvim", -- optional
- },
- },
-}
diff --git a/.config/nvim/lua/plugins/Mason.lua b/.config/nvim/lua/plugins/Mason.lua
deleted file mode 100644
index 496f4a1..0000000
--- a/.config/nvim/lua/plugins/Mason.lua
+++ /dev/null
@@ -1,67 +0,0 @@
-return {
- {
- "williamboman/mason.nvim",
- lazy = false, -- Load immediately to ensure PATH is set
- cmd = "Mason",
- build = ":MasonUpdate",
- opts = {
- ensure_installed = {
- -- LSP servers (matching your vim.lsp.enable() config)
- "lua-language-server", -- Lua LSP
- "gopls", -- Go LSP
- "vtsls", -- TypeScript LSP
- "tailwindcss-language-server", -- Tailwind CSS LSP
- "html-lsp", -- HTML LSP
- "css-lsp", -- CSS LSP
- "astro-language-server", -- Astro LSP
- "typescript-language-server",
- -- Formatters (for conform.nvim and general use)
- "stylua",
- "goimports",
- -- Note: gofmt comes with Go installation, not managed by Mason
- "prettier",
-
- -- Linters and diagnostics
- "golangci-lint",
- "eslint_d",
- -- "luacheck", -- Lua linting
-
- -- Additional useful tools
- "shfmt", -- Shell formatter
- "shellcheck", -- Shell linter
- },
- },
- config = function(_, opts)
- -- PATH is handled by core.mason-path for consistency
- require("mason").setup(opts)
-
- -- Auto-install ensure_installed tools with better error handling
- local mr = require("mason-registry")
- local function ensure_installed()
- for _, tool in ipairs(opts.ensure_installed) do
- if mr.has_package(tool) then
- local p = mr.get_package(tool)
- if not p:is_installed() then
- vim.notify("Mason: Installing " .. tool .. "...", vim.log.levels.INFO)
- p:install():once("closed", function()
- if p:is_installed() then
- vim.notify("Mason: Successfully installed " .. tool, vim.log.levels.INFO)
- else
- vim.notify("Mason: Failed to install " .. tool, vim.log.levels.ERROR)
- end
- end)
- end
- else
- vim.notify("Mason: Package '" .. tool .. "' not found", vim.log.levels.WARN)
- end
- end
- end
-
- if mr.refresh then
- mr.refresh(ensure_installed)
- else
- ensure_installed()
- end
- end,
- },
-}
diff --git a/.config/nvim/lua/plugins/ai.lua b/.config/nvim/lua/plugins/ai.lua
deleted file mode 100644
index 747bdb4..0000000
--- a/.config/nvim/lua/plugins/ai.lua
+++ /dev/null
@@ -1,17 +0,0 @@
-return {
- "supermaven-inc/supermaven-nvim",
- event = "InsertEnter",
- config = function()
- require("supermaven-nvim").setup({
- keymaps = {
- accept_suggestion = "",
- clear_suggestion = "",
- accept_word = "",
- },
- color = {
- suggestion_color = "#475284",
- cterm = 1,
- },
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/blink.lua b/.config/nvim/lua/plugins/blink.lua
deleted file mode 100644
index b745170..0000000
--- a/.config/nvim/lua/plugins/blink.lua
+++ /dev/null
@@ -1,77 +0,0 @@
-return {
- { "L3MON4D3/LuaSnip", keys = {} },
- {
- "saghen/blink.cmp",
- dependencies = {
- "rafamadriz/friendly-snippets",
- },
- event = "InsertEnter",
- version = "*",
- config = function()
- vim.cmd("highlight NormalFloat guibg=NONE blend=0")
- vim.cmd("highlight FloatBorder guibg=NONE blend=0")
- vim.cmd("highlight Pmenu guibg=NONE blend=0")
- vim.cmd("highlight PmenuSel guibg=NONE blend=0")
- require("blink.cmp").setup({
- snippets = { preset = "luasnip" },
- signature = { enabled = true },
- appearance = {
- use_nvim_cmp_as_default = false,
- nerd_font_variant = "mono",
- },
- sources = {
- -- per_filetype = {
- -- codecompanion = { "codecompanion" },
- -- },
- default = { "lsp", "path", "snippets", "buffer" },
- },
- keymap = { preset = "enter" },
- cmdline = {
- enabled = false,
- completion = {
- -- documentation = {
- -- auto_show = true,
- -- auto_show_delay_ms = 200,
- -- },
- menu = {
- auto_show = false,
- -- auto_show_delay_ms = 500,
- },
- },
- keymap = {
- [""] = { "accept_and_enter", "fallback" },
- },
- },
- completion = {
- accept = { auto_brackets = { enabled = false } },
- menu = {
- auto_show = true,
- border = nil,
- winhighlight = "NormalFloat:NormalFloat,FloatBorder:FloatBorder,Pmenu:Pmenu",
- scrolloff = 1,
- scrollbar = false,
- draw = {
- columns = {
- { "kind_icon" },
- { "label", "label_description", gap = 1 },
- { "kind" },
- { "source_name" },
- },
- },
- },
- documentation = {
- window = {
- border = nil,
- scrollbar = false,
- winhighlight = "Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,EndOfBuffer:BlinkCmpDoc",
- },
- auto_show = true,
- auto_show_delay_ms = 500,
- },
- },
- })
-
- require("luasnip.loaders.from_vscode").lazy_load()
- end,
- },
-}
diff --git a/.config/nvim/lua/plugins/conform.lua b/.config/nvim/lua/plugins/conform.lua
deleted file mode 100644
index fcfbbc3..0000000
--- a/.config/nvim/lua/plugins/conform.lua
+++ /dev/null
@@ -1,39 +0,0 @@
-return {
- "stevearc/conform.nvim",
- event = { "BufWritePre" },
- cmd = { "ConformInfo" },
- opts = {
- formatters_by_ft = {
- go = { "goimports", "gofmt" },
- nix = { "nixfmt" },
- lua = { "stylua" },
- javascript = { "prettier" },
- typescript = { "prettier" },
- javascriptreact = { "prettier" },
- typescriptreact = { "prettier" },
- json = { "prettier" },
- astro = { "prettier" },
- jsonc = { "prettier" },
- yaml = { "prettier" },
- markdown = { "prettier" },
- html = { "prettier" },
- css = { "prettier" },
- scss = { "prettier" },
- python = { "isort", "black" },
- php = { "pint" },
- sh = { "shfmt" },
- bash = { "shfmt" },
- rust = { "rustfmt" },
- },
- default_format_opts = {
- lsp_fallback = true,
- },
- format_on_save = {
- timeout_ms = 1000,
- lsp_fallback = true,
- },
- },
- init = function()
- vim.o.formatexpr = "v:lua.require'conform'.formatexpr()"
- end,
-}
diff --git a/.config/nvim/lua/plugins/cord.lua b/.config/nvim/lua/plugins/cord.lua
deleted file mode 100644
index 9d649a7..0000000
--- a/.config/nvim/lua/plugins/cord.lua
+++ /dev/null
@@ -1,15 +0,0 @@
-return {
- "vyfor/cord.nvim",
- build = ":Cord update",
- opts = {
- display = { theme = "atom", flavor = "dark" },
- editor = {
- client = "vim",
- tooltip = "the quick brown fox jumps over the lazy dog",
- },
- text = {
- editing = "[ Coding a ${filename} ] 🚀",
- },
- variables = true, -- Enable string templates
- },
-}
diff --git a/.config/nvim/lua/plugins/extra.lua b/.config/nvim/lua/plugins/extra.lua
deleted file mode 100644
index 37c8e69..0000000
--- a/.config/nvim/lua/plugins/extra.lua
+++ /dev/null
@@ -1,150 +0,0 @@
-return {
- -- moving around code
- {
- "folke/flash.nvim",
- event = "VeryLazy",
- ---@type Flash.Config
- opts = {
- prompt = {
- enabled = false,
- },
- },
- -- stylua: ignore
- keys = {
- { "s", mode = { "n", "x", "o" }, function() require("flash").jump() end, desc = "Flash" },
- { "S", mode = { "n", "x", "o" }, function() require("flash").treesitter() end, desc = "Flash Treesitter" },
- { "r", mode = "o", function() require("flash").remote() end, desc = "Remote Flash" },
- { "R", mode = { "o", "x" }, function() require("flash").treesitter_search() end, desc = "Treesitter Search" },
- { "", mode = { "c" }, function() require("flash").toggle() end, desc = "Toggle Flash Search" },
- },
- },
- {
- "mistweaverco/bafa.nvim",
- version = "v1.10.1",
- },
- -- tmux move with nevim
- {
- "christoomey/vim-tmux-navigator",
- event = "VeryLazy",
- },
- -- sessions
- {
- "folke/persistence.nvim",
- event = "BufReadPre", -- this will only start session saving when an actual file was opened
- opts = {
- -- add any custom options here
- },
- },
- -- showkeys
- {
- "nvzone/showkeys",
- cmd = "ShowkeysToggle",
- opts = {
- timeout = 1,
- maxkeys = 5,
- show_count = true,
- -- bottom-left, bottom-right, bottom-center, top-left, top-right, top-center
- position = "bottom-center",
- -- more opts
- },
- },
- -- moving around code
- {
- "echasnovski/mini.nvim",
- event = "VeryLazy",
- config = function()
- -- Better Around/Inside textobjects
- --
- -- Examples:
- -- - va) - [V]isually select [A]round [)]paren
- -- - yinq - [Y]ank [I]nside [N]ext [']quote
- -- - ci' - [C]hange [I]nside [']quote
- require("mini.ai").setup({ n_lines = 500 })
-
- -- Add/delete/replace surroundings (brackets, quotes, etc.)
- --
- -- - saiw) - [S]urround [A]dd [I]nner [W]ord [)]Paren
- -- - sd' - [S]urround [D]elete [']quotes
- -- - sr)' - [S]urround [R]eplace [)] [']
- require("mini.surround").setup({
- mappings = {
- add = "gsa", -- Add surrounding in Normal and Visual modes
- delete = "gsd", -- Delete surrounding
- find = "gsf", -- Find surrounding (to the right)
- find_left = "gsF", -- Find surrounding (to the left)
- highlight = "gsh", -- Highlight surrounding
- replace = "gsr", -- Replace surrounding
- update_n_lines = "gsn", -- Update `n_lines`
- },
- })
-
- require("mini.pairs").setup()
- end,
- },
- {
- "folke/todo-comments.nvim",
- dependencies = { "nvim-lua/plenary.nvim" },
- opts = {
- -- your configuration comes here
- },
- },
- {
- "uga-rosa/ccc.nvim",
- -- cond = not is_vscode(),
- event = { "BufReadPost" },
- opts = {
- highlighter = {
- auto_enable = true,
- lsp = false,
- },
- },
- },
-
- -- taking pic for the code
- -- {
- -- "mistricky/codesnap.nvim",
- -- build = "make",
- -- opts = {
- -- save_path = "~/Developer/codepic",
- -- has_breadcrumbs = true,
- -- bg_theme = "peach",
- -- watermark = "@x0d7x",
- -- },
- -- },
- -- dimming
- {
- "folke/twilight.nvim",
- opts = {
- -- your configuration comes here
- -- or leave it empty to use the default settings
- -- refer to the configuration section below
- },
- },
- {
- "folke/ts-comments.nvim",
- opts = {},
- event = "VeryLazy",
- enabled = vim.fn.has("nvim-0.10.0") == 1,
- },
- {
- "Wansmer/treesj",
- keys = {
- "m",
- "j",
- "s",
- },
- dependencies = { "nvim-treesitter/nvim-treesitter" }, -- if you install parsers with `nvim-treesitter`
- config = function()
- require("treesj").setup({--[[ your config ]]
- })
- end,
- },
- {
- "Root-lee/screensaver.nvim",
- config = function()
- require("screensaver").setup({
- idle_ms = 60 * 1000, -- Idle time in milliseconds (1 minute)
- })
- end,
- },
-}
diff --git a/.config/nvim/lua/plugins/fff.lua b/.config/nvim/lua/plugins/fff.lua
deleted file mode 100644
index e6965b9..0000000
--- a/.config/nvim/lua/plugins/fff.lua
+++ /dev/null
@@ -1,36 +0,0 @@
-return {
- "dmtrKovalenko/fff.nvim",
- build = function()
- -- this will download prebuild binary or try to use existing rustup toolchain to build from source
- -- (if you are using lazy you can use gb for rebuilding a plugin if needed)
- require("fff.download").download_or_build_binary()
- end,
- -- if you are using nixos
- -- build = "nix run .#release",
- opts = { -- (optional)
- debug = {
- enabled = true, -- we expect your collaboration at least during the beta
- show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
- },
- keymaps = {
- close = "q",
- move_up = "",
- move_down = "",
- },
- layout = {
- prompt_position = "bottom", -- or 'top'
- },
- },
- -- No need to lazy-load with lazy.nvim.
- -- This plugin initializes itself lazily.
- lazy = false,
- keys = {
- {
- "fm",
- function()
- require("fff").live_grep({ query = vim.fn.expand("") })
- end,
- desc = "Search current word",
- },
- },
-}
diff --git a/.config/nvim/lua/plugins/indent.lua b/.config/nvim/lua/plugins/indent.lua
deleted file mode 100644
index 974fbc3..0000000
--- a/.config/nvim/lua/plugins/indent.lua
+++ /dev/null
@@ -1,64 +0,0 @@
-return {
- "lukas-reineke/indent-blankline.nvim",
- main = "ibl",
- ---@module "ibl"
- ---@type ibl.config
- opts = {},
- config = function()
- local highlight = {
- "RainbowRed",
- "RainbowYellow",
- "RainbowBlue",
- "RainbowOrange",
- "RainbowGreen",
- "RainbowViolet",
- "RainbowCyan",
- }
-
- local hooks = require("ibl.hooks")
- -- create the highlight groups in the highlight setup hook, so they are reset
- -- every time the colorscheme changes
- hooks.register(hooks.type.HIGHLIGHT_SETUP, function()
- vim.api.nvim_set_hl(0, "RainbowRed", { fg = "#E06C75" })
- vim.api.nvim_set_hl(0, "RainbowYellow", { fg = "#E5C07B" })
- vim.api.nvim_set_hl(0, "RainbowBlue", { fg = "#61AFEF" })
- vim.api.nvim_set_hl(0, "RainbowOrange", { fg = "#D19A66" })
- vim.api.nvim_set_hl(0, "RainbowGreen", { fg = "#98C379" })
- vim.api.nvim_set_hl(0, "RainbowViolet", { fg = "#C678DD" })
- vim.api.nvim_set_hl(0, "RainbowCyan", { fg = "#56B6C2" })
- end)
-
- vim.g.rainbow_delimiters = { highlight = highlight }
- hooks.register(hooks.type.SCOPE_HIGHLIGHT, hooks.builtin.scope_highlight_from_extmark)
- require("ibl").setup({
- indent = {
- -- highlight = highlight,
- char = "┆",
- tab_char = "┆",
- },
- scope = {
- show_start = true,
- show_end = true,
- highlight = highlight,
- },
- exclude = {
- filetypes = {
- "Trouble",
- "alpha",
- "dashboard",
- "help",
- "lazy",
- "mason",
- "neo-tree",
- "notify",
- "snacks_dashboard",
- "snacks_notif",
- "snacks_terminal",
- "snacks_win",
- "toggleterm",
- "trouble",
- },
- },
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/linter.lua b/.config/nvim/lua/plugins/linter.lua
deleted file mode 100644
index 56c959a..0000000
--- a/.config/nvim/lua/plugins/linter.lua
+++ /dev/null
@@ -1,87 +0,0 @@
-return {
- "mfussenegger/nvim-lint",
- event = { "BufReadPost", "BufWritePost", "InsertLeave" },
- config = function()
- local lint = require("lint")
- -- Customize golangcilint to ignore exit codes (golangci-lint exits with code 1-3 when issues are found)
- local golangcilint = require("lint").linters.golangcilint
- golangcilint.ignore_exitcode = true
- -- Set filetype to linter mapping
- lint.linters_by_ft = {
- javascript = { "eslint_d" },
- typescript = { "eslint_d" },
- javascriptreact = { "eslint_d" },
- typescriptreact = { "eslint_d" },
- astro = { "eslint_d" },
- -- lua = { "luacheck" },
- go = { "golangcilint" },
- html = {},
- css = {},
- tailwindcss = {},
- dockerfile = {},
- }
- lint.linters.eslint_d = {
- cmd = "eslint_d",
- stdin = true,
- args = {
- "--format",
- "json",
- "--stdin",
- "--stdin-filename",
- function()
- return vim.api.nvim_buf_get_name(0)
- end,
- },
- stream = "stdout",
- ignore_exitcode = true,
- parser = require("lint.parser").from_errorformat("%f:%l:%c: %m", {
- source = "eslint_d",
- format = "json",
- -- if you have custom parsing logic you can use on_output instead of errorformat
- }),
- }
- -- Debounce function
- local function debounce(ms, fn)
- local timer = vim.uv.new_timer()
- return function(...)
- local argv = { ... }
- timer:start(ms, 0, function()
- timer:stop()
- vim.schedule_wrap(fn)(unpack(argv))
- end)
- end
- end
-
- -- Custom lint function with fallback and global linter support
- local function do_lint()
- local names = lint._resolve_linter_by_ft(vim.bo.filetype)
- names = vim.list_extend({}, names)
- vim.list_extend(names, lint.linters_by_ft._ or {})
- vim.list_extend(names, lint.linters_by_ft["*"] or {})
-
- local ctx = { filename = vim.api.nvim_buf_get_name(0) }
- ctx.dirname = vim.fn.fnamemodify(ctx.filename, ":h")
-
- names = vim.tbl_filter(function(name)
- local linter = lint.linters[name]
- if not linter then
- vim.notify("Linter not found: " .. name, vim.log.levels.WARN, { title = "nvim-lint" })
- end
- return linter and not (linter.condition and not linter.condition(ctx))
- end, names)
-
- if #names > 0 then
- lint.try_lint(names)
- end
- end
- -- vim.keymap.set("n", "ll", function()
- -- lint.try_lint()
- -- vim.notify("Linting...", vim.log.levels.INFO, { title = "nvim-lint" })
- -- end, { desc = "Trigger linting for current file" })
- -- Autocommand to trigger lint on specific events
- vim.api.nvim_create_autocmd({ "BufWritePost", "InsertLeave", "BufReadPost" }, {
- group = vim.api.nvim_create_augroup("nvim-lint", { clear = true }),
- callback = debounce(100, do_lint),
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/miniFile.lua b/.config/nvim/lua/plugins/miniFile.lua
deleted file mode 100644
index 3a03200..0000000
--- a/.config/nvim/lua/plugins/miniFile.lua
+++ /dev/null
@@ -1,48 +0,0 @@
-return {
- "echasnovski/mini.files",
- event = "VeryLazy",
- opts = {
- -- Module mappings created only inside explorer.
- -- Use `''` (empty string) to not create one.
- mappings = {
- synchronize = "s",
- },
- },
- -- make the last window expand to the eend of window
- -- by https://github.com/0xwal
- init = function()
- local MiniFiles = require("mini.files")
- local function update_width_of_last_window()
- local state = MiniFiles.get_explorer_state()
- if not state then
- return
- end
-
- local windows = state.windows
- if #windows == 1 then
- return
- end
-
- local lastWindow = windows[#windows]
- local totalWidth = 0
- for _, win in ipairs(windows) do
- if lastWindow.win_id ~= win.win_id then
- local config = vim.api.nvim_win_get_config(win.win_id)
- totalWidth = totalWidth + config.width + 2
- config.zindex = 1
- vim.api.nvim_win_set_config(win.win_id, config)
- end
- end
- local width = math.abs(vim.o.columns - (totalWidth + 2))
- local config = vim.api.nvim_win_get_config(lastWindow.win_id)
- config.width = width
- vim.api.nvim_win_set_config(lastWindow.win_id, config)
- end
- vim.api.nvim_create_autocmd("User", {
- pattern = "MiniFilesWindowUpdate",
- callback = function(_)
- update_width_of_last_window()
- end,
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/snaks.lua b/.config/nvim/lua/plugins/snaks.lua
deleted file mode 100644
index 16c935a..0000000
--- a/.config/nvim/lua/plugins/snaks.lua
+++ /dev/null
@@ -1,112 +0,0 @@
-return {
- "folke/snacks.nvim",
- priority = 1000,
- lazy = false,
- ---@type snacks.Config
- opts = {
- -- your configuration comes here
- -- or leave it empty to use the default settings
- -- refer to the configuration section below
- dashboard = {
- enabled = false,
- preset = {
- header = [[
-
- ██████╗ █████╗ ██╗ ███████╗███████╗████████╗██╗███╗ ██╗███████╗
- ██╔â•â•██╗██╔â•â•██╗██║ ██╔â•â•â•â•â•██╔â•â•â•â•â•╚â•â•██╔â•â•â•██║████╗ ██║██╔â•â•â•â•â•
-██████╔â•███████║██║ █████╗ ███████╗ ██║ ██║██╔██╗ ██║█████╗
-██╔â•â•â•╠██╔â•â•██║██║ ██╔â•â•╠╚â•â•â•â•██║ ██║ ██║██║╚██╗██║██╔â•â•â•
- ██║ ██║ ██║███████╗███████╗███████║ ██║ ██║██║ ╚████║███████╗
- ╚â•╠╚â•╠╚â•â•╚â•â•â•â•â•â•â•╚â•â•â•â•â•â•â•╚â•â•â•â•â•â•╠╚â•╠╚â•â•╚â•╠╚â•â•â•â•╚â•â•â•â•â•â•â•
-
-â¤ï¸ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤
-â¤ï¸â¤ï¸ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤
-â¤ï¸â¤ï¸â¤ï¸ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤ðŸ–¤
-â¤ï¸â¤ï¸â¤ï¸â¤ï¸ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤
-â¤ï¸â¤ï¸â¤ï¸â¤ï¸â¤ï¸ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤
-â¤ï¸â¤ï¸â¤ï¸â¤ï¸ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤ðŸ¤
-â¤ï¸â¤ï¸â¤ï¸ðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’š
-â¤ï¸â¤ï¸ðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’š
-â¤ï¸ðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’šðŸ’š
-]],
- },
- sections = {
- { section = "header", gap = 2 },
- { section = "startup" },
- { section = "keys", gap = 1, padding = { 2, 2 } },
- { icon = "ï…› ", title = "Recent Files", section = "recent_files", indent = 10, padding = { 1, 1 } },
- { icon = "ï¼ ", title = "Projects", section = "projects", indent = 10, padding = 2 },
- },
- },
- -- explorer = { enabled = true },
- bigfile = { enabled = true },
- dim = {
- enabled = true,
- padding = { 4, 4 }, -- lines to keep visible above/below (default: 1, 2)
- alpha = 0.4, -- dim intensity (0=invisible, 1=no dim, default: ~0.4)
- exclude = {},
- },
- indent = { enabled = false },
- input = { enabled = false },
- notifier = {
- enabled = true,
- timeout = 3000,
- },
- quickfile = { enabled = true },
- notify = { enabled = true },
- -- picker = {
- -- enabled = true,
- -- matcher = {
- -- frecency = true,
- -- },
- -- },
- scope = { enabled = true },
- scroll = { enabled = true },
- scratch = { enabled = true },
- words = { enabled = true },
- toggle = { enabled = true },
- image = { enabled = true },
- zen = { enabled = true },
- lazygit = { enabld = false },
- animate = { enabled = false },
- -- statuscolumn = { enabled = true },
- },
- init = function()
- vim.api.nvim_create_autocmd("User", {
- pattern = "VeryLazy",
- callback = function()
- -- Setup some globals for debugging (lazy-loaded)
- _G.dd = function(...)
- Snacks.debug.inspect(...)
- end
- _G.bt = function()
- Snacks.debug.backtrace()
- end
- vim.print = _G.dd -- Override print to use snacks for `:=` command
- Snacks.dim()
- -- Create some toggle mappings
- Snacks.toggle.scroll():map("uS")
- Snacks.toggle.animate():map("ua")
- Snacks.toggle.option("wrap", { name = "Wrap" }):map("uw")
- Snacks.toggle.option("relativenumber", { name = "Relative Number" }):map("uL")
- Snacks.toggle.diagnostics():map("ud")
- Snacks.toggle.line_number():map("ul")
- Snacks.toggle
- .option("conceallevel", { off = 0, on = vim.o.conceallevel > 0 and vim.o.conceallevel or 2 })
- :map("uc")
- Snacks.toggle.treesitter():map("uT")
- Snacks.toggle
- .option("background", { off = "light", on = "dark", name = "Dark Background" })
- :map("ub")
- Snacks.toggle.inlay_hints():map("uh")
- Snacks.toggle.indent():map("ug")
- -- Toggle the profiler
- Snacks.toggle.profiler():map("pp")
- -- Toggle the profiler highlights
- Snacks.toggle.profiler_highlights():map("ph")
- Snacks.toggle.zen():map("uz")
- Snacks.toggle.zoom():map("wm")
- end,
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/theme.lua b/.config/nvim/lua/plugins/theme.lua
deleted file mode 100644
index 57fefdd..0000000
--- a/.config/nvim/lua/plugins/theme.lua
+++ /dev/null
@@ -1,149 +0,0 @@
-return {
- {
- "vague2k/vague.nvim",
- opts = {
- transparent = false, -- don't set background
- colors = {
- visual = "#cb945b",
- comment = "#666666",
- },
- },
- config = function(_, opts)
- require("vague").setup(opts)
- vim.cmd.colorscheme("vague")
- end,
- },
- {
- "Aejkatappaja/sora",
- lazy = false,
- priority = 1000,
- opts = {
- transparent = true, -- don't set background
- },
- config = function(_, opts)
- require("sora").setup(opts)
- -- vim.cmd("colorscheme sora")
- end,
- },
- {
- "oskarnurm/koda.nvim",
- lazy = false, -- make sure we load this during startup if it is your main colorscheme
- priority = 1000, -- make sure to load this before all the other start plugins
- opts = {
- transparent = false, -- enable for transparent backgrounds
- on_highlights = function(hl, c)
- hl.Comment = { fg = "#666666", italic = true }
- end,
- },
- config = function(_, opts)
- -- require("koda").setup({ transparent = true })
- require("koda").setup(opts)
- -- vim.cmd("colorscheme koda")
- -- vim.cmd("colorscheme koda-dark")
- -- vim.cmd("colorscheme koda-light")
- -- vim.cmd("colorscheme koda-moss")
- -- vim.cmd("colorscheme koda-glade")
- end,
- },
- {
- "nendix/zen.nvim",
- lazy = false,
- priority = 1000,
- opts = {
- transparent = true, -- don't set background
- },
- config = function(_, opts)
- require("zen").setup(opts)
- -- vim.cmd.colorscheme("zen")
- end,
- },
- {
- "Koalhack/darcubox-nvim",
- opts = {
- options = {
- transparent = true, -- don't set background
- styles = {
- comments = { italic = true }, -- italic
- functions = { bold = true }, -- bold
- keywords = { italic = true },
- types = { italic = true, bold = true }, -- italics and bold
- },
- },
- },
- config = function(_, opts)
- require("darcubox").setup(opts)
- -- vim.cmd([[colorscheme darcubox]])
- end,
- },
- {
- "rebelot/kanagawa.nvim",
- opts = {
- transparent = true, -- do not set background color
- -- setup must be called before loading
- terminalColors = true,
- theme = "dragon",
- background = {
- dark = "dragon",
- },
- },
- config = function(_, opts)
- require("kanagawa").setup(opts)
- -- vim.cmd("colorscheme kanagawa-dragon")
- vim.api.nvim_set_hl(0, "LineNr", { fg = "#666666", bg = "NONE" })
- vim.api.nvim_set_hl(0, "Comment", { fg = "#666666", italic = true })
- vim.api.nvim_set_hl(0, "CursorLineNr", { fg = "#CC7653", bold = true, bg = "NONE" })
- end,
- },
- {
- "motaz-shokry/gruvbox",
- url = "https://gitlab.com/motaz-shokry/gruvbox.nvim.git",
- name = "gruvbox",
- enabled = true,
- priority = 1000,
- opts = {
- variant = "soft", -- hard, medium, soft, light
- styles = {
- transparency = true,
- },
- },
- config = function(_, opts)
- require("gruvbox").setup(opts)
- -- vim.cmd("colorscheme gruvbox-soft")
- end,
- },
- {
- "darianmorat/gruvdark.nvim",
- lazy = false,
- priority = 1000,
- opts = {
- transparent = true, -- Show or hide background
- },
- config = function(_, opts)
- require("gruvdark").setup(opts)
- -- vim.cmd.colorscheme("gruvdark")
- end,
- },
- {
- "connormxfadden/petrolnoir.nvim",
- priority = 1000, -- load before other UI plugins
- opts = {
- -- transparent = true, -- don't set background
- },
- config = function()
- -- vim.cmd.colorscheme("petrolnoir")
- vim.api.nvim_set_hl(0, "LineNr", { fg = "#666666" })
- vim.api.nvim_set_hl(0, "CursorLineNr", { fg = "#CC7653", bold = true })
- end,
- },
- {
- "mcauley-penney/techbase.nvim",
- priority = 1000,
- opts = {
- transparent = true, -- don't set background
- },
- config = function(_, opts)
- require("techbase").setup(opts)
- -- vim.cmd.colorscheme("techbase")
- end,
- },
-}
diff --git a/.config/nvim/lua/plugins/treesittr.lua b/.config/nvim/lua/plugins/treesittr.lua
deleted file mode 100644
index 9b0aa11..0000000
--- a/.config/nvim/lua/plugins/treesittr.lua
+++ /dev/null
@@ -1,115 +0,0 @@
-return {
- {
- "nvim-treesitter/nvim-treesitter",
- version = false,
- build = ":TSUpdate",
- event = { "BufReadPost", "BufNewFile" },
- dependencies = {
- "nvim-treesitter/nvim-treesitter-textobjects",
- },
- config = function()
- require("nvim-treesitter").setup({
- sync_install = false,
- modules = {},
- fold = {
- enable = true,
- },
- highlight = {
- enable = true,
- additional_vim_regex_highlighting = false,
- },
- indent = { enable = true },
- auto_install = true,
- ensure_installed = {
- "bash",
- "c",
- "html",
- "javascript",
- "json",
- "toml",
- "lua",
- "luadoc",
- "luap",
- "markdown",
- "markdown_inline",
- "python",
- "regex",
- "tsx",
- "typescript",
- "vue",
- "vim",
- "vimdoc",
- "yaml",
- "rust",
- "go",
- "gomod",
- "gowork",
- "gosum",
- "php",
- "astro",
- },
- incremental_selection = {
- enable = true,
- keymaps = {
- init_selection = "vv",
- node_incremental = "+",
- scope_incremental = false,
- node_decremental = "_",
- },
- },
- textobjects = {
- select = {
- enable = true,
- lookahead = true,
-
- keymaps = {
- -- You can use the capture groups defined in textobjects.scm
- ["af"] = { query = "@function.outer", desc = "around a function" },
- ["if"] = { query = "@function.inner", desc = "inner part of a function" },
- ["ac"] = { query = "@class.outer", desc = "around a class" },
- ["ic"] = { query = "@class.inner", desc = "inner part of a class" },
- ["ai"] = { query = "@conditional.outer", desc = "around an if statement" },
- ["ii"] = { query = "@conditional.inner", desc = "inner part of an if statement" },
- ["al"] = { query = "@loop.outer", desc = "around a loop" },
- ["il"] = { query = "@loop.inner", desc = "inner part of a loop" },
- ["ap"] = { query = "@parameter.outer", desc = "around parameter" },
- ["ip"] = { query = "@parameter.inner", desc = "inside a parameter" },
- },
- selection_modes = {
- ["@parameter.outer"] = "v", -- charwise
- ["@parameter.inner"] = "v", -- charwise
- ["@function.outer"] = "v", -- charwise
- ["@conditional.outer"] = "V", -- linewise
- ["@loop.outer"] = "V", -- linewise
- ["@class.outer"] = "", -- blockwise
- },
- include_surrounding_whitespace = false,
- },
- move = {
- enable = true,
- set_jumps = true, -- whether to set jumps in the jumplist
- goto_previous_start = {
- ["[f"] = { query = "@function.outer", desc = "Previous function" },
- ["[c"] = { query = "@class.outer", desc = "Previous class" },
- ["[p"] = { query = "@parameter.inner", desc = "Previous parameter" },
- },
- goto_next_start = {
- ["]f"] = { query = "@function.outer", desc = "Next function" },
- ["]c"] = { query = "@class.outer", desc = "Next class" },
- ["]p"] = { query = "@parameter.inner", desc = "Next parameter" },
- },
- },
- swap = {
- enable = true,
- swap_next = {
- ["a"] = "@parameter.inner",
- },
- swap_previous = {
- ["A"] = "@parameter.inner",
- },
- },
- },
- })
- end,
- },
-}
diff --git a/.config/nvim/lua/plugins/trouble.lua b/.config/nvim/lua/plugins/trouble.lua
deleted file mode 100644
index daaebc7..0000000
--- a/.config/nvim/lua/plugins/trouble.lua
+++ /dev/null
@@ -1,56 +0,0 @@
-return {
- "folke/trouble.nvim",
- event = "LspAttach",
- cmd = "Trouble",
- opts = {}, -- for default options, refer to the configuration section for custom setup.
- lazy = true,
- specs = {
- "folke/snacks.nvim",
- opts = function(_, opts)
- return vim.tbl_deep_extend("force", opts or {}, {
- picker = {
- actions = require("trouble.sources.snacks").actions,
- win = {
- input = {
- keys = {
- [""] = {
- "trouble_open",
- mode = { "n", "i" },
- },
- },
- },
- },
- },
- })
- end,
- },
- config = function()
- require("trouble").setup({
- -- "workspace_diagnostics", "document_diagnostics", "quickfix", "lsp_references", "loclist"
- mode = "workspace_diagnostics",
- position = "bottom", -- position of the list can be: bottom, top, left, right
- height = 15,
- padding = false,
- action_keys = {
- -- key mappings for actions in the trouble list
- close = "q", -- close the list
- cancel = "", -- cancel the preview and get back to your last window / buffer / cursor
- refresh = "r", -- manually refresh
- jump = { "", "" }, -- jump to the diagnostic or open / close folds
- open_split = { "" }, -- open buffer in new split
- open_vsplit = { "" }, -- open buffer in new vsplit
- open_tab = { "" }, -- open buffer in new tab
- jump_close = { "o" }, -- jump to the diagnostic and close the list
- toggle_mode = "m", -- toggle between "workspace" and "document" diagnostics mode
- toggle_preview = "P", -- toggle auto_preview
- hover = "K", -- opens a small popup with the full multiline message
- preview = "p", -- preview the diagnostic location
- close_folds = { "zM" }, -- close all folds
- open_folds = { "zR" }, -- open all folds
- toggle_fold = { "za" }, -- toggle fold of current file
- },
- auto_jump = {},
- use_diagnostic_signs = true,
- })
- end,
-}
diff --git a/.config/nvim/lua/plugins/which-key.lua b/.config/nvim/lua/plugins/which-key.lua
deleted file mode 100644
index f80b69c..0000000
--- a/.config/nvim/lua/plugins/which-key.lua
+++ /dev/null
@@ -1,57 +0,0 @@
-return {
- "folke/which-key.nvim",
- event = "VimEnter", -- Sets the loading event to 'VimEnter'
- opts = {
- preset = "helix",
- delay = 300,
- icons = {
- rules = false,
- breadcrumb = "ï„ ", -- symbol used in the command line area that shows your active key combo
- separator = "󱦰 ", -- symbol used between a key and it's label
- group = " ", -- symbol prepended to a group
- },
- plugins = {
- spelling = {
- enabled = false,
- },
- },
- win = {
- -- border = "none",
- height = {
- max = math.huge,
- },
- },
- spec = {
- {
- mode = { "n", "v" },
- { "f", group = "[F]ind" },
- { "g", group = "[G]it" },
- { "R", group = "[R]eplace" },
- { "l", group = "[L]SP" },
- { "c", group = "[C]ode", mode = { "n", "x" } },
- { "t", group = "[T]oggle" },
- { "p", group = "[P]rofiler" },
- { "D", group = "[D]ebugger" },
- { "s", group = "[S]earch" },
- { "x", group = "[D]iagnostics/quickfix (Trouble)", icon = { icon = "ó±–« ", color = "green" } },
- { "u", group = "[U]i", icon = { icon = "ó°™µ ", color = "cyan" } },
- { "b", group = "[B]uffer" },
- { "n", group = "[N]otify" },
- { "q", group = "[q]uit" },
- { "w", group = "[W]ind" },
- { "[", group = "prev" },
- { "]", group = "next" },
- { "g", group = "goto" },
- },
- },
- },
- keys = {
- {
- "?",
- function()
- require("which-key").show({ global = false })
- end,
- desc = "Buffer Local Keymaps (which-key)",
- },
- },
-}
diff --git a/.config/nvim/nvim-pack-lock.json b/.config/nvim/nvim-pack-lock.json
old mode 100644
new mode 100755
index a8ec33b..71d9540
--- a/.config/nvim/nvim-pack-lock.json
+++ b/.config/nvim/nvim-pack-lock.json
@@ -4,24 +4,34 @@
"rev": "642b0c595e11608b4c18219e93b88d7637af27bc",
"src": "https://github.com/L3MON4D3/LuaSnip"
},
+ "Oshen.nvim": {
+ "rev": "cc69305a7fdbc78452316bc90ae33c342b5d6f7f",
+ "src": "https://github.com/54L1M/Oshen.nvim"
+ },
"bafa.nvim": {
"rev": "7526f5fd61dba671c5fd571fb5351a031caf7b1a",
- "src": "https://github.com/mistweaverco/bafa.nvim"
+ "src": "https://github.com/mistweaverco/bafa.nvim",
+ "version": "'main'"
+ },
+ "black-metal-theme-neovim": {
+ "rev": "d7ce1c4b9196b3b34e70ca4fa9af108bb4207818",
+ "src": "https://github.com/metalelf0/black-metal-theme-neovim"
},
"blink.cmp": {
- "rev": "451168851e8e2466bc97ee3e026c3dcb9141ce07",
- "src": "https://github.com/saghen/blink.cmp"
+ "rev": "78336bc89ee5365633bcf754d93df01678b5c08f",
+ "src": "https://github.com/saghen/blink.cmp",
+ "version": "1.0.0 - 2.0.0"
},
"ccc.nvim": {
"rev": "9d1a256e006decc574789dfc7d628ca11644d4c2",
"src": "https://github.com/uga-rosa/ccc.nvim"
},
"conform.nvim": {
- "rev": "086a40dc7ed8242c03be9f47fbcee68699cc2395",
+ "rev": "619363c30309d29ffa631e67c8183f2a72caa373",
"src": "https://github.com/stevearc/conform.nvim"
},
"cord.nvim": {
- "rev": "283e4a5bdafd0d6187f28e5b739cccab6b4c9c64",
+ "rev": "e6ee37deea7b240d456a59aa7706eec006e56e79",
"src": "https://github.com/vyfor/cord.nvim"
},
"darcubox-nvim": {
@@ -33,7 +43,7 @@
"src": "https://github.com/sindrets/diffview.nvim"
},
"fff.nvim": {
- "rev": "db4cd2825c32e5e53e12bb8af106ff724d33904e",
+ "rev": "957f222da76f120868defdf9e7204309c3800e5e",
"src": "https://github.com/dmtrKovalenko/fff.nvim"
},
"flash.nvim": {
@@ -45,7 +55,7 @@
"src": "https://github.com/rafamadriz/friendly-snippets"
},
"gitsigns.nvim": {
- "rev": "944ef13cc8d8fe8b846c91f36041c8dfb85ca000",
+ "rev": "2038c666bd9d8a0b7349a0b6ee00dc83104b9ecf",
"src": "https://github.com/lewis6991/gitsigns.nvim"
},
"gruvbox": {
@@ -56,6 +66,10 @@
"rev": "94afaee342e8dd0fae054a15402568ec0235c720",
"src": "https://github.com/darianmorat/gruvdark.nvim"
},
+ "guh.nvim": {
+ "rev": "8e72fb815ed68c58b4f9f2c069b63603f4c54b91",
+ "src": "https://github.com/justinmk/guh.nvim"
+ },
"indent-blankline.nvim": {
"rev": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03",
"src": "https://github.com/lukas-reineke/indent-blankline.nvim"
@@ -65,39 +79,36 @@
"src": "https://github.com/rebelot/kanagawa.nvim"
},
"koda.nvim": {
- "rev": "a560a332ccc1eb2caacb280a390213bb9f37b3cb",
+ "rev": "a7da3ced59eadafbda6eb4d7f7e2a6a3d9ecf858",
"src": "https://github.com/oskarnurm/koda.nvim"
},
"mason.nvim": {
- "rev": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65",
+ "rev": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d",
"src": "https://github.com/williamboman/mason.nvim"
},
"mini.files": {
- "rev": "ced297546b8fdb8e215d416d4753a735514a2fe0",
+ "rev": "a5689dae6b732955e33eec225b798d6815063179",
"src": "https://github.com/echasnovski/mini.files"
},
"mini.nvim": {
- "rev": "402ee6c6ec8ea44b22330446c8fb4e615fd3953e",
+ "rev": "1345d191bb3da9c7b0e977f4387c5761f9bff68d",
"src": "https://github.com/echasnovski/mini.nvim"
},
"neogit": {
- "rev": "5a7fca171e3ad07380745d573d791e95268b8f3f",
+ "rev": "828dd073e6ad180fdb5c1603c7ad7f68a384c40b",
"src": "https://github.com/NeogitOrg/neogit"
},
"nvim-lint": {
- "rev": "4b03656c09c1561f89b6aa0665c15d292ba9499d",
+ "rev": "01c9842c089069ab497430159312b2c8868a4590",
"src": "https://github.com/mfussenegger/nvim-lint"
},
"nvim-treesitter": {
"rev": "4916d6592ede8c07973490d9322f187e07dfefac",
- "src": "https://github.com/nvim-treesitter/nvim-treesitter"
- },
- "nvim-treesitter-textobjects": {
- "rev": "93d60a475f0b08a8eceb99255863977d3a25f310",
- "src": "https://github.com/nvim-treesitter/nvim-treesitter-textobjects"
+ "src": "https://github.com/nvim-treesitter/nvim-treesitter",
+ "version": "'main'"
},
"nvim-web-devicons": {
- "rev": "40e9d5a6cc3db11b309e39593fc7ac03bb844e38",
+ "rev": "dfbfaa967a6f7ec50789bead7ef87e336c1fa63c",
"src": "https://github.com/nvim-tree/nvim-web-devicons"
},
"persistence.nvim": {
@@ -112,16 +123,12 @@
"rev": "b9fd5226c2f76c951fc8ed5923d85e4de065e509",
"src": "https://github.com/nvim-lua/plenary.nvim"
},
- "screensaver.nvim": {
- "rev": "38cf0b8a96b5f4a5e765ce32d43a416441462179",
- "src": "https://github.com/Root-lee/screensaver.nvim"
- },
"showkeys": {
"rev": "cb0a50296f11f1e585acffba8c253b9e8afc1f84",
"src": "https://github.com/nvzone/showkeys"
},
"snacks.nvim": {
- "rev": "ad9ede6a9cddf16cedbd31b8932d6dcdee9b716e",
+ "rev": "882c996cf28183f4d63640de0b4c02ec886d01f2",
"src": "https://github.com/folke/snacks.nvim"
},
"sora": {
@@ -133,17 +140,13 @@
"src": "https://github.com/supermaven-inc/supermaven-nvim"
},
"techbase.nvim": {
- "rev": "fc4e039e370043d7ae7203c9a1dbbda76f683740",
+ "rev": "90c5589e22a5723b535e48df1d4c675ae096341e",
"src": "https://github.com/mcauley-penney/techbase.nvim"
},
"todo-comments.nvim": {
"rev": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668",
"src": "https://github.com/folke/todo-comments.nvim"
},
- "treesj": {
- "rev": "26bc2a8432ba3ea79ed6aa346fba780a3d372570",
- "src": "https://github.com/Wansmer/treesj"
- },
"trouble.nvim": {
"rev": "bd67efe408d4816e25e8491cc5ad4088e708a69a",
"src": "https://github.com/folke/trouble.nvim"
@@ -157,7 +160,7 @@
"src": "https://github.com/folke/twilight.nvim"
},
"vague.nvim": {
- "rev": "8c4643f8a5106dbc492918f86ff6b5c81111ce9a",
+ "rev": "49fe06ad33d8847b931ba4279197bda222374cd1",
"src": "https://github.com/vague2k/vague.nvim"
},
"vim-tmux-navigator": {
@@ -169,7 +172,7 @@
"src": "https://github.com/folke/which-key.nvim"
},
"zen.nvim": {
- "rev": "9a60e3fbef521f754cb54a78106647873a50a8ae",
+ "rev": "e552fc12f847113a05589981d02f69c953d6d3e9",
"src": "https://github.com/nendix/zen.nvim"
}
}
diff --git a/.config/nvim/plugin/ai.lua b/.config/nvim/plugin/ai.lua
new file mode 100755
index 0000000..352246f
--- /dev/null
+++ b/.config/nvim/plugin/ai.lua
@@ -0,0 +1,15 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/supermaven-inc/supermaven-nvim" })
+
+require("supermaven-nvim").setup({
+ keymaps = {
+ accept_suggestion = "",
+ clear_suggestion = "",
+ accept_word = "",
+ },
+ color = {
+ suggestion_color = "#475284",
+ cterm = 1,
+ },
+})
diff --git a/.config/nvim/plugin/blink.lua b/.config/nvim/plugin/blink.lua
new file mode 100755
index 0000000..90f54ff
--- /dev/null
+++ b/.config/nvim/plugin/blink.lua
@@ -0,0 +1,66 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/L3MON4D3/LuaSnip",
+ "https://github.com/rafamadriz/friendly-snippets",
+ { src = "https://github.com/saghen/blink.cmp", version = vim.version.range("1.*") },
+})
+
+vim.cmd("highlight NormalFloat guibg=NONE blend=0")
+vim.cmd("highlight FloatBorder guibg=NONE blend=0")
+vim.cmd("highlight Pmenu guibg=NONE blend=0")
+vim.cmd("highlight PmenuSel guibg=NONE blend=0")
+
+require("blink.cmp").setup({
+ snippets = { preset = "luasnip" },
+ signature = { enabled = true },
+ appearance = {
+ use_nvim_cmp_as_default = false,
+ nerd_font_variant = "mono",
+ },
+ fuzzy = { implementation = "prefer_rust" },
+ sources = {
+ default = { "lsp", "path", "snippets", "buffer" },
+ },
+ keymap = { preset = "enter" },
+ cmdline = {
+ enabled = true,
+ completion = {
+ menu = {
+ auto_show = true,
+ },
+ },
+ keymap = {
+ [""] = { "accept_and_enter", "fallback" },
+ },
+ },
+ completion = {
+ accept = { auto_brackets = { enabled = false } },
+ menu = {
+ auto_show = true,
+ border = nil,
+ winhighlight = "NormalFloat:NormalFloat,FloatBorder:FloatBorder,Pmenu:Pmenu",
+ scrolloff = 1,
+ scrollbar = false,
+ draw = {
+ columns = {
+ { "kind_icon" },
+ { "label", "label_description", gap = 1 },
+ { "kind" },
+ { "source_name" },
+ },
+ },
+ },
+ documentation = {
+ window = {
+ border = nil,
+ scrollbar = false,
+ winhighlight = "Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,EndOfBuffer:BlinkCmpDoc",
+ },
+ auto_show = true,
+ auto_show_delay_ms = 500,
+ },
+ },
+})
+
+require("luasnip.loaders.from_vscode").lazy_load()
diff --git a/.config/nvim/plugin/cord.lua b/.config/nvim/plugin/cord.lua
new file mode 100755
index 0000000..0691c1b
--- /dev/null
+++ b/.config/nvim/plugin/cord.lua
@@ -0,0 +1,15 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/vyfor/cord.nvim" })
+
+require("cord").setup({
+ display = { theme = "atom", flavor = "dark" },
+ editor = {
+ client = "vim",
+ tooltip = "the quick brown fox jumps over the lazy dog",
+ },
+ text = {
+ editing = "[ Coding a ${filename} ] 🚀",
+ },
+ variables = true,
+})
diff --git a/.config/nvim/plugin/extra.lua b/.config/nvim/plugin/extra.lua
new file mode 100755
index 0000000..709b7d8
--- /dev/null
+++ b/.config/nvim/plugin/extra.lua
@@ -0,0 +1,64 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/folke/flash.nvim",
+ { src = "https://github.com/mistweaverco/bafa.nvim", version = "main" },
+ "https://github.com/christoomey/vim-tmux-navigator",
+ "https://github.com/folke/persistence.nvim",
+ "https://github.com/nvzone/showkeys",
+ "https://github.com/folke/todo-comments.nvim",
+ "https://github.com/nvim-lua/plenary.nvim",
+ "https://github.com/uga-rosa/ccc.nvim",
+ "https://github.com/folke/twilight.nvim",
+})
+
+require("flash").setup({
+ prompt = {
+ enabled = false,
+ },
+})
+
+vim.keymap.set({ "n", "x", "o" }, "s", function()
+ require("flash").jump()
+end, { desc = "Flash" })
+
+vim.keymap.set({ "n", "x", "o" }, "S", function()
+ require("flash").treesitter()
+end, { desc = "Flash Treesitter" })
+
+vim.keymap.set("o", "r", function()
+ require("flash").remote()
+end, { desc = "Remote Flash" })
+
+vim.keymap.set({ "o", "x" }, "R", function()
+ require("flash").treesitter_search()
+end, { desc = "Treesitter Search" })
+
+vim.keymap.set("c", "", function()
+ require("flash").toggle()
+end, { desc = "Toggle Flash Search" })
+
+require("persistence").setup({})
+
+require("showkeys").setup({
+ timeout = 1,
+ maxkeys = 5,
+ show_count = true,
+ position = "bottom-center",
+})
+
+require("todo-comments").setup({})
+
+require("ccc").setup({
+ highlighter = {
+ auto_enable = true,
+ lsp = false,
+ },
+})
+
+require("twilight").setup({})
+
+if vim.fn.has("nvim-0.10.0") == 1 then
+ pack.add({ "https://github.com/folke/ts-comments.nvim" })
+ require("ts-comments").setup({})
+end
diff --git a/.config/nvim/plugin/fff.lua b/.config/nvim/plugin/fff.lua
new file mode 100755
index 0000000..398cd3d
--- /dev/null
+++ b/.config/nvim/plugin/fff.lua
@@ -0,0 +1,47 @@
+local pack = require("core.pack")
+
+vim.g.fff = {
+ prompt_vim_mode = false,
+ lazy_sync = true,
+ debug = {
+ enabled = true,
+ show_scores = true,
+ },
+ keymaps = {
+ close = "q",
+ move_up = "",
+ move_down = "",
+ },
+ layout = {
+ prompt_position = "top",
+ },
+}
+
+pack.add({ "https://github.com/dmtrKovalenko/fff.nvim" })
+
+vim.api.nvim_create_autocmd("PackChanged", {
+ callback = function(ev)
+ local name, kind = ev.data.spec.name, ev.data.kind
+ if name == "fff.nvim" and (kind == "install" or kind == "update") then
+ if not ev.data.active then
+ vim.cmd.packadd("fff.nvim")
+ end
+ require("fff.download").download_or_build_binary()
+ end
+ end,
+})
+
+vim.keymap.set("n", "fm", function()
+ require("fff").live_grep({ query = vim.fn.expand("") })
+end, { desc = "Search current word" })
+
+vim.api.nvim_create_autocmd("User", {
+ pattern = "VeryLazy",
+ callback = function()
+ local plugin_dir = vim.fn.stdpath("data") .. "/site/pack/core/opt/fff.nvim"
+ local binary = plugin_dir .. "/target/release/libfff_nvim.so"
+ if vim.fn.filereadable(binary) ~= 1 then
+ require("fff.download").download_or_build_binary()
+ end
+ end,
+})
diff --git a/.config/nvim/plugin/format.lua b/.config/nvim/plugin/format.lua
new file mode 100755
index 0000000..d6b70d1
--- /dev/null
+++ b/.config/nvim/plugin/format.lua
@@ -0,0 +1,122 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/stevearc/conform.nvim",
+ "https://github.com/mfussenegger/nvim-lint",
+})
+
+require("conform").setup({
+ formatters_by_ft = {
+ go = { "goimports", "gofmt" },
+ nix = { "nixfmt" },
+ lua = { "stylua" },
+ javascript = { "prettier" },
+ typescript = { "prettier" },
+ javascriptreact = { "prettier" },
+ typescriptreact = { "prettier" },
+ json = { "prettier" },
+ astro = { "prettier" },
+ jsonc = { "prettier" },
+ yaml = { "prettier" },
+ markdown = { "prettier" },
+ html = { "prettier" },
+ css = { "prettier" },
+ scss = { "prettier" },
+ python = { "isort", "black" },
+ php = { "pint" },
+ sh = { "shfmt" },
+ bash = { "shfmt" },
+ rust = { "rustfmt" },
+ -- kdl = { "kdlfmt" },
+ },
+ -- formatters = {
+ -- kdlfmt = {
+ -- command = "kdlfmt",
+ -- args = { "format" },
+ -- },
+ -- },
+ default_format_opts = {
+ lsp_fallback = true,
+ },
+ format_on_save = {
+ timeout_ms = 1000,
+ lsp_fallback = true,
+ },
+})
+
+vim.o.formatexpr = "v:lua.require'conform'.formatexpr()"
+
+local lint = require("lint")
+local golangcilint = require("lint").linters.golangcilint
+golangcilint.ignore_exitcode = true
+
+lint.linters_by_ft = {
+ javascript = { "eslint_d" },
+ typescript = { "eslint_d" },
+ javascriptreact = { "eslint_d" },
+ typescriptreact = { "eslint_d" },
+ astro = { "eslint_d" },
+ go = { "golangcilint" },
+ html = {},
+ css = {},
+ tailwindcss = {},
+ dockerfile = {},
+}
+
+lint.linters.eslint_d = {
+ cmd = "eslint_d",
+ stdin = true,
+ args = {
+ "--format",
+ "json",
+ "--stdin",
+ "--stdin-filename",
+ function()
+ return vim.api.nvim_buf_get_name(0)
+ end,
+ },
+ stream = "stdout",
+ ignore_exitcode = true,
+ parser = require("lint.parser").from_errorformat("%f:%l:%c: %m", {
+ source = "eslint_d",
+ format = "json",
+ }),
+}
+
+local function debounce(ms, fn)
+ local timer = vim.uv.new_timer()
+ return function(...)
+ local argv = { ... }
+ timer:start(ms, 0, function()
+ timer:stop()
+ vim.schedule_wrap(fn)(unpack(argv))
+ end)
+ end
+end
+
+local function do_lint()
+ local names = lint._resolve_linter_by_ft(vim.bo.filetype)
+ names = vim.list_extend({}, names)
+ vim.list_extend(names, lint.linters_by_ft._ or {})
+ vim.list_extend(names, lint.linters_by_ft["*"] or {})
+
+ local ctx = { filename = vim.api.nvim_buf_get_name(0) }
+ ctx.dirname = vim.fn.fnamemodify(ctx.filename, ":h")
+
+ names = vim.tbl_filter(function(name)
+ local linter = lint.linters[name]
+ if not linter then
+ vim.notify("Linter not found: " .. name, vim.log.levels.WARN, { title = "nvim-lint" })
+ end
+ return linter and not (linter.condition and not linter.condition(ctx))
+ end, names)
+
+ if #names > 0 then
+ lint.try_lint(names)
+ end
+end
+
+vim.api.nvim_create_autocmd({ "BufWritePost", "InsertLeave", "BufReadPost" }, {
+ group = vim.api.nvim_create_augroup("nvim-lint", { clear = true }),
+ callback = debounce(100, do_lint),
+})
diff --git a/.config/nvim/plugin/ghu.lua b/.config/nvim/plugin/ghu.lua
new file mode 100644
index 0000000..80210e0
--- /dev/null
+++ b/.config/nvim/plugin/ghu.lua
@@ -0,0 +1 @@
+vim.pack.add({ "https://github.com/justinmk/guh.nvim" })
diff --git a/.config/nvim/plugin/git.lua b/.config/nvim/plugin/git.lua
new file mode 100755
index 0000000..54a48ec
--- /dev/null
+++ b/.config/nvim/plugin/git.lua
@@ -0,0 +1,42 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/lewis6991/gitsigns.nvim",
+ "https://github.com/sindrets/diffview.nvim",
+ "https://github.com/nvim-tree/nvim-web-devicons",
+ "https://github.com/NeogitOrg/neogit",
+ "https://github.com/nvim-lua/plenary.nvim",
+ "https://github.com/folke/snacks.nvim",
+})
+
+require("gitsigns").setup({
+ signs = {
+ add = { text = "ï§" },
+ change = { text = "|" },
+ delete = { text = "ï¨" },
+ topdelete = { text = "ó°†´" },
+ changedelete = { text = "ó°µ" },
+ untracked = { text = "î«" },
+ },
+ signs_staged = {
+ add = { text = "ï•" },
+ change = { text = "ï‘™" },
+ delete = { text = "ï–" },
+ topdelete = { text = "" },
+ changedelete = { text = "" },
+ untracked = { text = "î«" },
+ },
+})
+
+require("diffview").setup({
+ keymaps = {
+ disable_defaults = false,
+ view = {
+ { "n", "q", "tabclose", { desc = "close diffview" } },
+ },
+ },
+})
+
+require("neogit").setup({
+ graph_style = "kitty",
+})
diff --git a/.config/nvim/plugin/indent.lua b/.config/nvim/plugin/indent.lua
new file mode 100755
index 0000000..bf2092f
--- /dev/null
+++ b/.config/nvim/plugin/indent.lua
@@ -0,0 +1,57 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/lukas-reineke/indent-blankline.nvim" })
+
+local highlight = {
+ "RainbowRed",
+ "RainbowYellow",
+ "RainbowBlue",
+ "RainbowOrange",
+ "RainbowGreen",
+ "RainbowViolet",
+ "RainbowCyan",
+}
+
+local hooks = require("ibl.hooks")
+hooks.register(hooks.type.HIGHLIGHT_SETUP, function()
+ vim.api.nvim_set_hl(0, "RainbowRed", { fg = "#E06C75" })
+ vim.api.nvim_set_hl(0, "RainbowYellow", { fg = "#E5C07B" })
+ vim.api.nvim_set_hl(0, "RainbowBlue", { fg = "#61AFEF" })
+ vim.api.nvim_set_hl(0, "RainbowOrange", { fg = "#D19A66" })
+ vim.api.nvim_set_hl(0, "RainbowGreen", { fg = "#98C379" })
+ vim.api.nvim_set_hl(0, "RainbowViolet", { fg = "#C678DD" })
+ vim.api.nvim_set_hl(0, "RainbowCyan", { fg = "#56B6C2" })
+end)
+
+vim.g.rainbow_delimiters = { highlight = highlight }
+hooks.register(hooks.type.SCOPE_HIGHLIGHT, hooks.builtin.scope_highlight_from_extmark)
+
+require("ibl").setup({
+ indent = {
+ char = "┆",
+ tab_char = "┆",
+ },
+ scope = {
+ show_start = true,
+ show_end = true,
+ highlight = highlight,
+ },
+ exclude = {
+ filetypes = {
+ "Trouble",
+ "alpha",
+ "dashboard",
+ "help",
+ "lazy",
+ "mason",
+ "neo-tree",
+ "notify",
+ "snacks_dashboard",
+ "snacks_notif",
+ "snacks_terminal",
+ "snacks_win",
+ "toggleterm",
+ "trouble",
+ },
+ },
+})
diff --git a/.config/nvim/plugin/mason.lua b/.config/nvim/plugin/mason.lua
new file mode 100755
index 0000000..9ae2392
--- /dev/null
+++ b/.config/nvim/plugin/mason.lua
@@ -0,0 +1,53 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/williamboman/mason.nvim" })
+
+local opts = {
+ ensure_installed = {
+ "lua-language-server",
+ "gopls",
+ "vtsls",
+ "tsgo",
+ "tailwindcss-language-server",
+ "html-lsp",
+ "css-lsp",
+ "astro-language-server",
+ "typescript-language-server",
+ "stylua",
+ "goimports",
+ "prettier",
+ "golangci-lint",
+ "eslint_d",
+ "shfmt",
+ "shellcheck",
+ },
+}
+
+require("mason").setup(opts)
+
+local mr = require("mason-registry")
+local function ensure_installed()
+ for _, tool in ipairs(opts.ensure_installed) do
+ if mr.has_package(tool) then
+ local p = mr.get_package(tool)
+ if not p:is_installed() then
+ vim.notify("Mason: Installing " .. tool .. "...", vim.log.levels.INFO)
+ p:install():once("closed", function()
+ if p:is_installed() then
+ vim.notify("Mason: Successfully installed " .. tool, vim.log.levels.INFO)
+ else
+ vim.notify("Mason: Failed to install " .. tool, vim.log.levels.ERROR)
+ end
+ end)
+ end
+ else
+ vim.notify("Mason: Package '" .. tool .. "' not found", vim.log.levels.WARN)
+ end
+ end
+end
+
+if mr.refresh then
+ mr.refresh(ensure_installed)
+else
+ ensure_installed()
+end
diff --git a/.config/nvim/plugin/mini.lua b/.config/nvim/plugin/mini.lua
new file mode 100755
index 0000000..19d47d5
--- /dev/null
+++ b/.config/nvim/plugin/mini.lua
@@ -0,0 +1,34 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/echasnovski/mini.nvim",
+ "https://github.com/echasnovski/mini.files",
+})
+
+require("mini.ai").setup({ n_lines = 500 })
+
+require("mini.surround").setup({
+ mappings = {
+ add = "gsa",
+ delete = "gsd",
+ find = "gsf",
+ find_left = "gsF",
+ highlight = "gsh",
+ replace = "gsr",
+ update_n_lines = "gsn",
+ },
+})
+
+require("mini.pairs").setup()
+
+require("mini.files").setup({
+ mappings = {
+ synchronize = "s",
+ },
+ -- windows = {
+ -- preview = true,
+ -- width_preview = 50,
+ -- width_focus = 50,
+ -- width_nofocus = 0,
+ -- },
+})
diff --git a/.config/nvim/plugin/snacks.lua b/.config/nvim/plugin/snacks.lua
new file mode 100755
index 0000000..2a9c998
--- /dev/null
+++ b/.config/nvim/plugin/snacks.lua
@@ -0,0 +1,64 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/folke/snacks.nvim" })
+
+local Snacks = require("snacks")
+
+Snacks.setup({
+ dashboard = { enabled = false },
+ bigfile = { enabled = true },
+ dim = {
+ enabled = true,
+ padding = { 4, 4 },
+ alpha = 0.4,
+ exclude = {},
+ },
+ indent = { enabled = false },
+ input = { enabled = false },
+ notifier = {
+ enabled = false,
+ -- timeout = 3000,
+ },
+ quickfile = { enabled = true },
+ notify = { enabled = false },
+ scope = { enabled = true },
+ scroll = { enabled = true },
+ scratch = { enabled = true },
+ words = { enabled = true },
+ toggle = { enabled = true },
+ image = { enabled = true },
+ zen = { enabled = true },
+ lazygit = { enabled = false },
+ animate = { enabled = false },
+})
+
+vim.api.nvim_create_autocmd("User", {
+ pattern = "VeryLazy",
+ callback = function()
+ _G.dd = function(...)
+ Snacks.debug.inspect(...)
+ end
+ _G.bt = function()
+ Snacks.debug.backtrace()
+ end
+ vim.print = _G.dd
+ Snacks.dim()
+ Snacks.toggle.scroll():map("uS")
+ Snacks.toggle.animate():map("ua")
+ Snacks.toggle.option("wrap", { name = "Wrap" }):map("uw")
+ Snacks.toggle.option("relativenumber", { name = "Relative Number" }):map("uL")
+ Snacks.toggle.diagnostics():map("ud")
+ Snacks.toggle.line_number():map("ul")
+ Snacks.toggle
+ .option("conceallevel", { off = 0, on = vim.o.conceallevel > 0 and vim.o.conceallevel or 2 })
+ :map("uc")
+ Snacks.toggle.treesitter():map("uT")
+ Snacks.toggle.option("background", { off = "light", on = "dark", name = "Dark Background" }):map("ub")
+ Snacks.toggle.inlay_hints():map("uh")
+ Snacks.toggle.indent():map("ug")
+ Snacks.toggle.profiler():map("pp")
+ Snacks.toggle.profiler_highlights():map("ph")
+ Snacks.toggle.zen():map("uz")
+ Snacks.toggle.zoom():map("wm")
+ end,
+})
diff --git a/.config/nvim/plugin/theme.lua b/.config/nvim/plugin/theme.lua
new file mode 100755
index 0000000..7ffcfb7
--- /dev/null
+++ b/.config/nvim/plugin/theme.lua
@@ -0,0 +1,116 @@
+local pack = require("core.pack")
+
+pack.add({
+ "https://github.com/metalelf0/black-metal-theme-neovim",
+ "https://github.com/vague2k/vague.nvim",
+ "https://github.com/Aejkatappaja/sora",
+ "https://github.com/oskarnurm/koda.nvim",
+ "https://github.com/nendix/zen.nvim",
+ "https://github.com/Koalhack/darcubox-nvim",
+ "https://github.com/rebelot/kanagawa.nvim",
+ { src = "https://gitlab.com/motaz-shokry/gruvbox.nvim.git", name = "gruvbox" },
+ "https://github.com/darianmorat/gruvdark.nvim",
+ "https://github.com/connormxfadden/petrolnoir.nvim",
+ "https://github.com/mcauley-penney/techbase.nvim",
+ "https://github.com/54L1M/Oshen.nvim",
+})
+require("oshen").setup({
+ transparent = true, -- set false for opaque background
+})
+-- vim.cmd.colorscheme("Oshen")
+require("black-metal").setup({ -- Can be one of: bathory | burzum | dark-funeral | darkthrone | emperor | gorgoroth | immortal | impaled-nazarene | khold | marduk | mayhem | nile | taake | thyrfing | venom | windir
+ theme = "bathory",
+ -- Can be one of: 'light' | 'dark', or set via vim.o.background
+ variant = "dark",
+ -- Use an alternate, lighter bg
+ alt_bg = false,
+ -- If true, docstrings will be highlighted like strings, otherwise they will be
+ -- highlighted like comments. Note, behavior is dependent on the language server.
+ colored_docstrings = true,
+ -- If true, highlights the {sign,fold} column the same as cursorline
+ cursorline_gutter = true,
+ -- If true, highlights the gutter darker than the bg
+ dark_gutter = false,
+ -- if true favor treesitter highlights over semantic highlights
+ favor_treesitter_hl = false,
+ -- Don't set background of floating windows. Recommended for when using floating
+ -- windows with borders.
+ plain_float = true,
+ -- Show the end-of-buffer character
+ show_eob = true,
+ -- If true, enable the vim terminal colors
+ term_colors = true,
+ -- Keymap (in normal mode) to toggle between light and dark variants.
+ toggle_variant_key = nil,
+ -- Don't set background
+ transparent = true,
+})
+-- require("black-metal").load()
+require("vague").setup({
+ transparent = true,
+ colors = {
+ visual = "#cb945b",
+ comment = "#666666",
+ },
+})
+-- vim.cmd.colorscheme("vague")
+
+require("sora").setup({ transparent = true })
+
+-- vim.cmd("colorscheme sora")
+
+require("koda").setup({
+ transparent = true,
+ on_highlights = function(hl)
+ hl.Comment = { fg = "#666666", italic = true }
+ end,
+})
+-- vim.cmd("colorscheme koda-dark")
+
+require("zen").setup({ transparent = false })
+
+-- vim.cmd("colorscheme zen")
+
+require("darcubox").setup({
+ options = {
+ transparent = true,
+ styles = {
+ comments = { italic = true },
+ functions = { bold = true },
+ keywords = { italic = true },
+ types = { italic = true, bold = true },
+ },
+ },
+})
+
+require("kanagawa").setup({
+ transparent = true,
+ terminalColors = true,
+ theme = "dragon",
+ background = {
+ dark = "dragon",
+ },
+})
+
+require("gruvbox").setup({
+ variant = "soft",
+ styles = {
+ transparency = true,
+ },
+})
+vim.cmd.colorscheme("gruvbox")
+
+require("gruvdark").setup({ transparent = false })
+
+require("techbase").setup({ transparent = true })
+-- vim.cmd.colorscheme("techbase")
+
+require("petrolnoir").setup({ transparent = true })
+-- vim.api.nvim_set_hl(0, "CursorLine", {
+-- fg = "#ffffff",
+-- bg = "#CC7653",
+-- bold = true,
+-- })
+-- vim.api.nvim_set_hl(0, "LineNr", { fg = "#666666", bg = "NONE" })
+-- vim.api.nvim_set_hl(0, "Comment", { fg = "#666666", italic = true })
+-- vim.api.nvim_set_hl(0, "CursorLineNr", { fg = "#CC7653", bold = true, bg = "NONE" })
diff --git a/.config/nvim/plugin/treesitter.lua b/.config/nvim/plugin/treesitter.lua
new file mode 100755
index 0000000..4537508
--- /dev/null
+++ b/.config/nvim/plugin/treesitter.lua
@@ -0,0 +1,81 @@
+local pack = require("core.pack")
+
+pack.add({
+ { src = "https://github.com/nvim-treesitter/nvim-treesitter", version = "main" },
+})
+
+local ensure_installed = {
+ "bash",
+ "c",
+ "html",
+ "javascript",
+ "json",
+ "toml",
+ "lua",
+ "luadoc",
+ "luap",
+ "markdown",
+ "markdown_inline",
+ "python",
+ "regex",
+ "tsx",
+ "typescript",
+ "vue",
+ "vim",
+ "vimdoc",
+ "yaml",
+ "rust",
+ "go",
+ "gomod",
+ "gowork",
+ "gosum",
+ "php",
+ "astro",
+}
+
+-- Install parsers on buffer enter
+local installed = {}
+
+vim.api.nvim_create_autocmd("FileType", {
+ callback = function(args)
+ local buf = args.buf
+
+ if vim.bo[buf].buftype ~= "" then
+ return
+ end
+
+ local ft = vim.bo[buf].filetype
+ if not ft or ft == "" then
+ return
+ end
+
+ local ok, lang = pcall(vim.treesitter.language.get_lang, ft)
+ if not ok or not lang then
+ return
+ end
+
+ if not installed[lang] then
+ installed[lang] = true
+ require("nvim-treesitter").install(lang)
+ end
+
+ pcall(vim.treesitter.start, buf, lang)
+
+ vim.bo[buf].indentexpr = "v:lua.vim.treesitter.indentexpr()"
+
+ local win = vim.fn.bufwinid(buf)
+ if win ~= -1 then
+ vim.wo[win].foldexpr = "v:lua.vim.treesitter.foldexpr()"
+ vim.wo[win].foldmethod = "expr"
+ end
+ end,
+})
+
+-- Install ensure_installed parsers on startup
+vim.api.nvim_create_autocmd("User", {
+ pattern = "VeryLazy",
+ callback = function()
+ local ts = require("nvim-treesitter")
+ ts.install(ensure_installed)
+ end,
+})
diff --git a/.config/nvim/plugin/trouble.lua b/.config/nvim/plugin/trouble.lua
new file mode 100755
index 0000000..54a903c
--- /dev/null
+++ b/.config/nvim/plugin/trouble.lua
@@ -0,0 +1,51 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/folke/trouble.nvim" })
+
+require("trouble").setup({
+ mode = "workspace_diagnostics",
+ position = "bottom",
+ height = 15,
+ padding = false,
+ action_keys = {
+ close = "q",
+ cancel = "",
+ refresh = "r",
+ jump = { "", "" },
+ open_split = { "" },
+ open_vsplit = { "" },
+ open_tab = { "" },
+ jump_close = { "o" },
+ toggle_mode = "m",
+ toggle_preview = "P",
+ hover = "K",
+ preview = "p",
+ close_folds = { "zM" },
+ open_folds = { "zR" },
+ toggle_fold = { "za" },
+ },
+ auto_jump = {},
+ use_diagnostic_signs = true,
+})
+
+local ok_snacks, snacks = pcall(require, "snacks")
+if ok_snacks and type(snacks.setup) == "function" then
+ local ok_trouble, trouble_snacks = pcall(require, "trouble.sources.snacks")
+ if ok_trouble then
+ snacks.setup({
+ picker = {
+ actions = trouble_snacks.actions,
+ win = {
+ input = {
+ keys = {
+ [""] = {
+ "trouble_open",
+ mode = { "n", "i" },
+ },
+ },
+ },
+ },
+ },
+ })
+ end
+end
diff --git a/.config/nvim/plugin/which-key.lua b/.config/nvim/plugin/which-key.lua
new file mode 100755
index 0000000..4aa6cab
--- /dev/null
+++ b/.config/nvim/plugin/which-key.lua
@@ -0,0 +1,51 @@
+local pack = require("core.pack")
+
+pack.add({ "https://github.com/folke/which-key.nvim" })
+
+require("which-key").setup({
+ preset = "helix",
+ delay = 300,
+ icons = {
+ rules = false,
+ breadcrumb = "ï„ ",
+ separator = "󱦰 ",
+ group = " ",
+ },
+ plugins = {
+ spelling = {
+ enabled = false,
+ },
+ },
+ win = {
+ height = {
+ max = math.huge,
+ },
+ },
+ spec = {
+ {
+ mode = { "n", "v" },
+ { "f", group = "[F]ind" },
+ { "g", group = "[G]it" },
+ { "R", group = "[R]eplace" },
+ { "l", group = "[L]SP" },
+ { "c", group = "[C]ode", mode = { "n", "x" } },
+ { "t", group = "[T]oggle" },
+ { "p", group = "[P]rofiler" },
+ { "D", group = "[D]ebugger" },
+ { "s", group = "[S]earch" },
+ { "x", group = "[D]iagnostics/quickfix (Trouble)", icon = { icon = "ó±–« ", color = "green" } },
+ { "u", group = "[U]i", icon = { icon = "ó°™µ ", color = "cyan" } },
+ { "b", group = "[B]uffer" },
+ { "n", group = "[N]otify" },
+ { "q", group = "[q]uit" },
+ { "w", group = "[W]ind" },
+ { "[", group = "prev" },
+ { "]", group = "next" },
+ { "g", group = "goto" },
+ },
+ },
+})
+
+vim.keymap.set("n", "?", function()
+ require("which-key").show({ global = false })
+end, { desc = "Buffer Local Keymaps (which-key)" })
diff --git a/.config/opencode/.agentic.json b/.config/opencode/.agentic.json
new file mode 100644
index 0000000..9b490e7
--- /dev/null
+++ b/.config/opencode/.agentic.json
@@ -0,0 +1,1541 @@
+{
+ "_agentic": {
+ "generated_by": "agentic",
+ "repository": "https://github.com/sawrus/agent-guides",
+ "created_by": "v0.4.0",
+ "updated_by": "v0.4.0"
+ },
+ "version": 1,
+ "created_at": "2026-06-20T17:39:52Z",
+ "updated_at": "2026-06-20T17:39:52Z",
+ "settings": {
+ "agent_os": [
+ "opencode"
+ ],
+ "areas": [
+ "software"
+ ],
+ "specializations": [
+ "software.backend",
+ "software.full-stack",
+ "software.general"
+ ],
+ "mcp_integrations": [
+ "opencode-docs"
+ ],
+ "opencode_profile": "",
+ "opencode_plugins": {
+ "telegram": {
+ "enabled": false,
+ "botToken": "",
+ "chatId": ""
+ },
+ "agentModelMapper": {
+ "enabled": false
+ }
+ },
+ "source_repo": "https://github.com/sawrus/agent-guides",
+ "source_checkout": "/home/dox/.local/share/agentic/repo"
+ },
+ "managed_files": [
+ {
+ "path": ".agent/prompts/add-migration.md",
+ "source": "areas/software/backend/prompts/add-migration.md",
+ "content_hash": "5cf9612c8830b31526f6f43f505236d4701cf53121d15ac507dd747a1713e950",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/backend-project-full-cycle.md",
+ "source": "areas/software/full-stack/prompts/backend-project-full-cycle.md",
+ "content_hash": "95b4f325cf84a27da5a09b3ade7da1c52b481290b5419f17a1d5490c1a8cec1c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/code-review-workflow.md",
+ "source": "areas/software/general/prompts/code-review-workflow.md",
+ "content_hash": "f04d36c49ee965b9fe6c12c97d2c8ac17a96cfaff1780294884e6c6fcfab57fa",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/create-endpoint.md",
+ "source": "areas/software/backend/prompts/create-endpoint.md",
+ "content_hash": "4bd5bb83d1c6d2402656cd9e31c739140aba2be5a796d498c7b6bf1e0e040931",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/debug-issue.md",
+ "source": "areas/software/full-stack/prompts/debug-issue.md",
+ "content_hash": "59cb2d21fc24103857d6185c00f51cea90fe8196ea05a2e565e61cee000bc45c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/develop-epic.md",
+ "source": "areas/software/backend/prompts/develop-epic.md",
+ "content_hash": "682e5d0a65e92f4eed052a6b3870268bed006e8d9c6bbfc57efb0dd20e6cb02e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/develop-feature.md",
+ "source": "areas/software/full-stack/prompts/develop-feature.md",
+ "content_hash": "d6dbd508504ffc609965e3d17022e18fd686dd9f9b261974de7580facb769c99",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/development-cycle-workflow.md",
+ "source": "areas/software/general/prompts/development-cycle-workflow.md",
+ "content_hash": "3575a652d9aebceb44231bccf862b7ccbeaf575861f36ffd7dbdf6ef26e18e56",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/feature-implementation-flow.md",
+ "source": "areas/software/full-stack/prompts/feature-implementation-flow.md",
+ "content_hash": "9feb3b9614d5e45383eb669176824eee28ff9e1b395e136b202753d642e5d3ed",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/project-setup-workflow.md",
+ "source": "areas/software/general/prompts/project-setup-workflow.md",
+ "content_hash": "62c1b0dfa7d156e9213cb5b61fafb488239f9c435ddd057087c7f031148cef59",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/refactor-module.md",
+ "source": "areas/software/backend/prompts/refactor-module.md",
+ "content_hash": "32fe07c7ad31097725e6e190d66764988c3dbf9b69ac2d704945e1e38a49f3bc",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/test-feature.md",
+ "source": "areas/software/backend/prompts/test-feature.md",
+ "content_hash": "bf852113b1e4dec541311b166869144fc68fa5f2e0be948cfb7136c464ece53f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/prompts/testing-ci-pipeline.md",
+ "source": "areas/software/full-stack/prompts/testing-ci-pipeline.md",
+ "content_hash": "b0b1a5b33b1de7dda0765263aa012571396292769a59d43cbcc6c08896bfeca1",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/api-design-guide.md",
+ "source": "areas/software/full-stack/rules/api-design-guide.md",
+ "content_hash": "e81082b9cf8ccce5494198730f4956928f42d82a0abf4d1803fb92f53384924e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/architecture.md",
+ "source": "areas/software/backend/rules/architecture.md",
+ "content_hash": "c4b2729430da1c0f25fd15516b053befebc8d45600922a655fa5066d9044a304",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/async-concurrency-guide.md",
+ "source": "areas/software/full-stack/rules/async-concurrency-guide.md",
+ "content_hash": "750799b62b921f8d2b5faf6bca1780ce16c4fa763b677803fcea83e6e0460583",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/backend-architecture-rule.md",
+ "source": "areas/software/full-stack/rules/backend-architecture-rule.md",
+ "content_hash": "ba14c8cb24c52d9840f919c2a3fc4134d4cae99341706402e27aa80bf8ef9505",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/background-jobs-guide.md",
+ "source": "areas/software/full-stack/rules/background-jobs-guide.md",
+ "content_hash": "6ad7cbf99f62774ada1f711a305b8d9b9a83079bd681014d4eb172cdcfdbfc06",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/code-quality-guide.md",
+ "source": "areas/software/full-stack/rules/code-quality-guide.md",
+ "content_hash": "d6533e21854291f29ddbd2405083912b337367189af9511b3b3fe0af9d9d8209",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/code-style-guide.md",
+ "source": "areas/software/general/rules/code-style-guide.md",
+ "content_hash": "f49c486853820f32e0ecf928c5579146e0cd5be72594bd28279f8b94d3ebe1e4",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/data_access.md",
+ "source": "areas/software/backend/rules/data_access.md",
+ "content_hash": "b987f1eebe5d8b2279d4b85d43d732344ed6a1a43f8d9ecf27d9b0638e90c2e5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/database-access-guide.md",
+ "source": "areas/software/full-stack/rules/database-access-guide.md",
+ "content_hash": "a68da153877f7b079ff3cfd0f5cc27c9c23e11ca73f28682bcb1df04df881cbd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/database-migrations-guide.md",
+ "source": "areas/software/full-stack/rules/database-migrations-guide.md",
+ "content_hash": "7249c445cd5ad16ade437f7c02b0c111e700ba3995f69b37fa684b5ec35a9654",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/docker-compose-guide.md",
+ "source": "areas/software/general/rules/docker-compose-guide.md",
+ "content_hash": "12eeea14effbec3b8a33728de4c81b083c87458a73eae05bccd0a9813258a7fa",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/domain-models-guide.md",
+ "source": "areas/software/full-stack/rules/domain-models-guide.md",
+ "content_hash": "6de441308ceb5cd60d0ed6ae4b81d8d01ff6c53a7b874c0255dfbd7b847e3f36",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/e2e-test-guide.md",
+ "source": "areas/software/full-stack/rules/e2e-test-guide.md",
+ "content_hash": "774c28f425f2a73a5db65e4a0b509df4fc9912694dcfe0783253bfac6c5e6468",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/env-settings-guide.md",
+ "source": "areas/software/full-stack/rules/env-settings-guide.md",
+ "content_hash": "d295707c1ae334e1da2ccbcb945fa789f5f51b5d29bbd6d13d716031405400ba",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/error-handling-guide.md",
+ "source": "areas/software/full-stack/rules/error-handling-guide.md",
+ "content_hash": "6f0db1dc09e2b81fc42889f28a7c32863fa0b77ae63825faa09485bd0c1e7483",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/git-workflow-guide.md",
+ "source": "areas/software/general/rules/git-workflow-guide.md",
+ "content_hash": "bac22f1ef7d33d5f95185af77b835c6dd879ef2232484ee106171b47a5c0fd11",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/github-workflow-guide.md",
+ "source": "areas/software/general/rules/github-workflow-guide.md",
+ "content_hash": "e8fda1f0a076b83e1f78506ce54ed17b8b0ee382c61cabd802c93411ece17e75",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/gitlab-ci-guide.md",
+ "source": "areas/software/general/rules/gitlab-ci-guide.md",
+ "content_hash": "fc9bbaac01d84b0f517cc51b3ed3dc493fec68c7111d9bff6d7f69eabeebc48c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/lint-format-guide.md",
+ "source": "areas/software/general/rules/lint-format-guide.md",
+ "content_hash": "bd00ee10c90c54b5193a996df436faac46ae9d6a2bc2b201a927e15fc5e6262d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/logging-observability-guide.md",
+ "source": "areas/software/full-stack/rules/logging-observability-guide.md",
+ "content_hash": "53b75fdbf010f2735cd54ae349f8a5c3b08e348bb4112fc757bed19d59da6377",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/makefile-guide.md",
+ "source": "areas/software/general/rules/makefile-guide.md",
+ "content_hash": "91f24f2804112b03fb8fed7705d44050be7512e8352b4f438e788162e3ddc00a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/project-guide.md",
+ "source": "areas/software/full-stack/rules/project-guide.md",
+ "content_hash": "1b831d5528e915d0785b186159bffb99d2758e96d5fc99883d3e49885dbe74a0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/python-venv-guide.md",
+ "source": "areas/software/full-stack/rules/python-venv-guide.md",
+ "content_hash": "1827c76663aec2ffc7680475d60b582210ff406c127abff525e89b708ff8545d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/readme-sync-guide.md",
+ "source": "areas/software/general/rules/readme-sync-guide.md",
+ "content_hash": "5c7e39181194b34bfa4814e99ae665b079a1deef396b66f015285628a99b5d70",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/sdlc-methodology-guide.md",
+ "source": "areas/software/general/rules/sdlc-methodology-guide.md",
+ "content_hash": "09c9c996727224987a8f9fc07462ca5ca1921d3b399f0c0486a400cf619c7cf7",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/sdlc-role-responsibilities.md",
+ "source": "areas/software/general/rules/sdlc-role-responsibilities.md",
+ "content_hash": "43d4ea2fbff127213c25acba8d74051352bff4b0e0a9c327f02d4a6fc032657e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/security-guide.md",
+ "source": "areas/software/full-stack/rules/security-guide.md",
+ "content_hash": "035bab7f0cdeaebf7075a49609938e634be691336fe65a4474e8c78fe39c3b1c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/security.md",
+ "source": "areas/software/backend/rules/security.md",
+ "content_hash": "561cf9cd3285da477f1d4000712ccca100b902c4af2b93f6338fd899d8eaabe3",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/svt-test-guide.md",
+ "source": "areas/software/full-stack/rules/svt-test-guide.md",
+ "content_hash": "8d0d28a84723a4a9da402cb6f16c80c1d31817be749f886ec6ccccb62a46043f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/testing-ci-guide.md",
+ "source": "areas/software/full-stack/rules/testing-ci-guide.md",
+ "content_hash": "be133361dde8587eb573b6ad8e3f51124a90f59edb900eee4e8b7b25aed94ab6",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/rules/testing.md",
+ "source": "areas/software/backend/rules/testing.md",
+ "content_hash": "2d97a5a75323e49affa2e8d62cc5a2db0d609c35538f0efa08b479115fa5671b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/SKILL.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/SKILL.md",
+ "content_hash": "3886ab15e191ff89653b8c0c21321deb94aef2e1f07f520175cffab941456c07",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/assets/api-design-checklist.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/assets/api-design-checklist.md",
+ "content_hash": "53569577e2ff0370fd5db5e33b40efc88da311f2dc31b1c8c94caaba37ec1c0a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/assets/rest-api-template.py",
+ "source": "areas/software/full-stack/skills/api-design-principles/assets/rest-api-template.py",
+ "content_hash": "e4c51f4dbad9c5c2f02a37bcf2ad9b560300c1d3959188d4d76124d220b90f51",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/references/graphql-schema-design.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/references/graphql-schema-design.md",
+ "content_hash": "3b0e2ed56500d466ef3939c9e1bd21bea125fd15ef8a2c8eedfa9ab219e8e1a4",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/references/rest-best-practices.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/references/rest-best-practices.md",
+ "content_hash": "250d817f2f83f2d0c48d30a0a58fd2f1adb253b474faca23eae59f2c89fc8b51",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design-principles/resources/implementation-playbook.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/resources/implementation-playbook.md",
+ "content_hash": "dda495da043206d8d2467b9e815de489a64a3e295100b67f7656194b0da05c00",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-design/SKILL.md",
+ "source": "areas/software/backend/skills/api-design/SKILL.md",
+ "content_hash": "ac92f61337167053ceb843584807c8c1678a765397cb97e7838779d9344f6fec",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/SKILL.md",
+ "source": "areas/software/full-stack/skills/api-patterns/SKILL.md",
+ "content_hash": "1e3b22e8ae8866dc16b3fedd30c2bc7ff201ac7b27562e9b855fa68faef64347",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/api-style.md",
+ "source": "areas/software/full-stack/skills/api-patterns/api-style.md",
+ "content_hash": "2093c21d41fc04e83a163936ef33481fb95c07f740edb46d322eb125a2fc00b0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/auth.md",
+ "source": "areas/software/full-stack/skills/api-patterns/auth.md",
+ "content_hash": "23a6c70e2036cacb221a8470654aab3208d6415fbb425bb7926788bedfd76d30",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/documentation.md",
+ "source": "areas/software/full-stack/skills/api-patterns/documentation.md",
+ "content_hash": "1884bdead17689106ce21124aae5a78aae723a1d140623ff6b86b1b113e2d153",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/graphql.md",
+ "source": "areas/software/full-stack/skills/api-patterns/graphql.md",
+ "content_hash": "4ca8a7344e70081f9674a40630b1ce72530578a1de0b4bf0ed0c15c0daf9d6f0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/rate-limiting.md",
+ "source": "areas/software/full-stack/skills/api-patterns/rate-limiting.md",
+ "content_hash": "749e4959b24be1b52a1afe118c5d07057e78e665d2681c379fbf5659902fbb70",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/response.md",
+ "source": "areas/software/full-stack/skills/api-patterns/response.md",
+ "content_hash": "d4a8f52b17be4f39390b52d3becd5de9ee2a114fbfa32f9d1beba16f07281e72",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/rest.md",
+ "source": "areas/software/full-stack/skills/api-patterns/rest.md",
+ "content_hash": "906cecb6ed1385c5fe00799f593302a4ed8395895c89925f65b96a93922f3004",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/scripts/api_validator.py",
+ "source": "areas/software/full-stack/skills/api-patterns/scripts/api_validator.py",
+ "content_hash": "c09e803b9bc62bdd833d37925b8643980e0d56dd6aad180d3b6cf6c3b0889349",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/security-testing.md",
+ "source": "areas/software/full-stack/skills/api-patterns/security-testing.md",
+ "content_hash": "67c06db63aa0431c49fa30333960b8f79fbe55c6a7848708d9c885d8b2d0649f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/trpc.md",
+ "source": "areas/software/full-stack/skills/api-patterns/trpc.md",
+ "content_hash": "a1746ce93139ab250eff2684f721c6be3d5f9cd6f7ea3da075174baeaf449859",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/api-patterns/versioning.md",
+ "source": "areas/software/full-stack/skills/api-patterns/versioning.md",
+ "content_hash": "250e095d07a2f1a2537d734b99de9db2baa71428bb4ee62470d87b795590abb0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/SKILL.md",
+ "source": "areas/software/full-stack/skills/app-builder/SKILL.md",
+ "content_hash": "fa54cd8fb8400b50285868c01cc139a7052696a39fab9717d49b5b28585e250c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/agent-coordination.md",
+ "source": "areas/software/full-stack/skills/app-builder/agent-coordination.md",
+ "content_hash": "8f5476c510699dec80066506dd4b3e6d90cecd45631fb3919eb355ffb0cb8705",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/feature-building.md",
+ "source": "areas/software/full-stack/skills/app-builder/feature-building.md",
+ "content_hash": "35f6753183bc23157042c9d2d93366c9bf2b7ebb95e3ef38465dbcb1c0ec89e3",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/project-detection.md",
+ "source": "areas/software/full-stack/skills/app-builder/project-detection.md",
+ "content_hash": "0f991848a61bc35fa53987596925516916e3e9448be5d9a31e9d1aad8182de53",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/scaffolding.md",
+ "source": "areas/software/full-stack/skills/app-builder/scaffolding.md",
+ "content_hash": "4e8a4878fb576b72fd6b599b03ba3cbd8271291cb15180ebce6cfcd714fddb84",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/tech-stack.md",
+ "source": "areas/software/full-stack/skills/app-builder/tech-stack.md",
+ "content_hash": "fbd9f1deee8bd161d14cea7c9b66cff6d4ca13e285257e965b28b8fdfcb243f5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/SKILL.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/SKILL.md",
+ "content_hash": "cfb041ad2818b92bd85bb430296062f1b14e3769cc4c719051a4f138d5089bbd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/astro-static/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/astro-static/TEMPLATE.md",
+ "content_hash": "58fe2d96ec7f710eaf9c8b595982328ef7d6fa9c8c5174985b588d1baccdab5d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/chrome-extension/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/chrome-extension/TEMPLATE.md",
+ "content_hash": "d5b32b9cbfb6d23c4b1ec897c1177f3eab1a622d976d3fd4f01def83e0659f03",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/cli-tool/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/cli-tool/TEMPLATE.md",
+ "content_hash": "7a3f410855a9d2f302c166ac0827fea760535a0b5780561a66d5ead8d2a7dc89",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/electron-desktop/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/electron-desktop/TEMPLATE.md",
+ "content_hash": "4b3b2ac3938ae06786162795c803340ee139306fb8bb613187bf7d9213de7c74",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/express-api/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/express-api/TEMPLATE.md",
+ "content_hash": "a42a0d3ddf21ab1a5d1d3b10858d1f354477cba873a523a4531d4af876efe8a7",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/flutter-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/flutter-app/TEMPLATE.md",
+ "content_hash": "a0aa2e6118e9364f4d5225d3b51433f1854f2195bf6f1097b76ec116975b58ef",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/monorepo-turborepo/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/monorepo-turborepo/TEMPLATE.md",
+ "content_hash": "5f41699fa098e10b116852a62db66ea79eba6f2f23db5902c346402f99836c22",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/nextjs-fullstack/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-fullstack/TEMPLATE.md",
+ "content_hash": "4054efc0f42c3da874dc0d2f69ebfa918e7b08096fbf9e9feac098a5390325a9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/nextjs-saas/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-saas/TEMPLATE.md",
+ "content_hash": "1c37021e2736d5efd8ca6f58903f9398faeef6ee0b2ebdbd29793644ab2ed978",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/nextjs-static/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-static/TEMPLATE.md",
+ "content_hash": "2cccd2d59b1df289885e08f927b1f46bc478ab8282014773bb811738a762302b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/nuxt-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nuxt-app/TEMPLATE.md",
+ "content_hash": "ba87406e7774635b359e3831f8952d01bdd8e2ee8eb7a8989794451f46a96ca6",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/python-fastapi/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/python-fastapi/TEMPLATE.md",
+ "content_hash": "196df34b055a7bd7fde05e9f90a94b55d5a6077afb3af34ab9b19ad778ea2bcb",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/app-builder/templates/react-native-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/react-native-app/TEMPLATE.md",
+ "content_hash": "d24c1793e238d765f69e45e1fc40514647f1697169600941b6dd4ea61f738061",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/async-processing/SKILL.md",
+ "source": "areas/software/backend/skills/async-processing/SKILL.md",
+ "content_hash": "8496f7a391065a29d836eed1e28ce8b7abf5333608bbfded66f0d63159e845b2",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/backend-developer/SKILL.md",
+ "source": "areas/software/full-stack/skills/backend-developer/SKILL.md",
+ "content_hash": "bde94a119fa5ad8c9b125a62997db1e6bc9b80e0218a08ddbd1b7fb298047c14",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/blackbox-test/SKILL.md",
+ "source": "areas/software/full-stack/skills/blackbox-test/SKILL.md",
+ "content_hash": "ae4a8b6f273b6004984ba4e50a1d789c743a04af9b66c7c7c371dd8f5cb1bcf2",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/database-modeling/SKILL.md",
+ "source": "areas/software/backend/skills/database-modeling/SKILL.md",
+ "content_hash": "d456fcc53f1e25a28650be5a44518f36354c8d1e0d7feae8e8d12d85675b552d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/general-dev-tools/SKILL.md",
+ "source": "areas/software/general/skills/general-dev-tools/SKILL.md",
+ "content_hash": "8ac088f3856a7605a7dc31a974ccfe9cb3481e4c708162f0d0bb3fc2ae113d8a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/observability/SKILL.md",
+ "source": "areas/software/backend/skills/observability/SKILL.md",
+ "content_hash": "09cd27911a474ca248db5dd7f0e647c774f630529d987de5d15a08c88efc18cd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/prompt-project-planner/SKILL.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/SKILL.md",
+ "content_hash": "1a3e708f8ef6ff872a714394d6ca89cb2be7fa4d50860f5308b218bce1bcc35e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/prompt-project-planner/output.schema.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/output.schema.md",
+ "content_hash": "18fbf01444d282c51392919dd0084e33564a40480d3f38015cddfab6bf513c6b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/prompt-project-planner/questions.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/questions.md",
+ "content_hash": "902d03ff6cb3c49cfe16cb14042747d4b102c7f4deec36daa403f9b435e0bb40",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/skills/troubleshooting/SKILL.md",
+ "source": "areas/software/backend/skills/troubleshooting/SKILL.md",
+ "content_hash": "9a5827f647eb6d0d1acb42d92d1896f9188d95f06ae45ef7fec3b89ac9af4e6d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/add-migration.md",
+ "source": "areas/software/backend/workflows/add-migration.md",
+ "content_hash": "bd44e8d554b221e3289e004ee6b917464a4351b651f154a2a103228be0c9917e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/backend-project-full-cycle.md",
+ "source": "areas/software/full-stack/workflows/backend-project-full-cycle.md",
+ "content_hash": "69cad926cfb58f576cb9a20503dcae96f21ece6f1c9751e11c888bcf29fd0e20",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/code-review-workflow.md",
+ "source": "areas/software/general/workflows/code-review-workflow.md",
+ "content_hash": "bd810a35f37c58827dc5de5bddb3306d74f225a2ad95cb647664683495233388",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/create-endpoint.md",
+ "source": "areas/software/backend/workflows/create-endpoint.md",
+ "content_hash": "63d7ff9030fb0ccfb4fbb1b0604f79fbd8a619b2bae4556e1249e5345bbfeae9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/debug-issue.md",
+ "source": "areas/software/full-stack/workflows/debug-issue.md",
+ "content_hash": "f881ddf3c181720dacef263b011466a92b6e904475a8df55e670b6ffcb647f6d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/develop-epic.md",
+ "source": "areas/software/backend/workflows/develop-epic.md",
+ "content_hash": "8395fbe6f51505d06a092454cba7653434564d2bfa06065c463baaf1756a4011",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/develop-feature.md",
+ "source": "areas/software/full-stack/workflows/develop-feature.md",
+ "content_hash": "4016cebec81741efc512c92c36e97381aaf37aa32bcad2b934788706e28152bf",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/development-cycle-workflow.md",
+ "source": "areas/software/general/workflows/development-cycle-workflow.md",
+ "content_hash": "46fa9677d5f328900b1c34f8516db0c1860d1044e970efb3cc7729d52c0ea4e9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/feature-implementation-flow.md",
+ "source": "areas/software/full-stack/workflows/feature-implementation-flow.md",
+ "content_hash": "d96aee0a614a96bc57af0ccd0f852d3cd6ac4025fc689183561514916b72277b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/project-setup-workflow.md",
+ "source": "areas/software/general/workflows/project-setup-workflow.md",
+ "content_hash": "c35e4dc60033fce3a797da794df5dd249de7bfba07d28e2b8e24664a42586780",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/refactor-module.md",
+ "source": "areas/software/backend/workflows/refactor-module.md",
+ "content_hash": "a2037b7a5ddc65041647127f0b0cf1b312decf07711916ef993691a21f940a07",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/test-feature.md",
+ "source": "areas/software/backend/workflows/test-feature.md",
+ "content_hash": "d53995c3bda5f0f9b643615e104f4a19ff357be9cb3fa10c9909f431e77b75cf",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".agent/workflows/testing-ci-pipeline.md",
+ "source": "areas/software/full-stack/workflows/testing-ci-pipeline.md",
+ "content_hash": "b71c2ee351cc59f6ce7d49726a993f02f3ab32d1a873d272e7e4448f7fc0e66c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/AGENTS.md",
+ "source": "generated:AGENTS.md",
+ "content_hash": "56fc18030e1a0295f5ee21f3c849a824d372a309f7adfe34035647805261d7a9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/MEMORY.md",
+ "source": "generated:MEMORY.md",
+ "content_hash": "4c6328bc31c8cfb4c8f7e113155c1c564031046d21f8e89d4b8ee6ebdd88e213",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/designer.md",
+ "source": "extensions/opencode/agents/designer.md",
+ "content_hash": "87083c370691918f9b8c6801b6ed4c0d0c3500dd679bb1ce0cf62ffa341aedb5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/developer.md",
+ "source": "extensions/opencode/agents/developer.md",
+ "content_hash": "2aa4edc91e267fd767b9fc6d2e79619247f0fbba6a8e2abf58d0628a4c8c3ee0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/devops-engineer.md",
+ "source": "extensions/opencode/agents/devops-engineer.md",
+ "content_hash": "6f00154defbe00c6dadbdb6f9e1cd45589d851fb378083f67ea6db27ef22fdc5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/instruction_reviewer.md",
+ "source": "extensions/opencode/agents/instruction_reviewer.md",
+ "content_hash": "3862cc9ecd0f6b0c15984eaab6658bb9ccac5cfa188e6a46e9a8ab47a8ee2197",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/memory_curator.md",
+ "source": "extensions/opencode/agents/memory_curator.md",
+ "content_hash": "4f48a39a2a073544d042f812e1e7f4dc4da76bfb88a455bad6e8192b53048b06",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/pm.md",
+ "source": "extensions/opencode/agents/pm.md",
+ "content_hash": "1bf60a8e89d20c9c45d4063601924e3bf0b8a5f2cbfab1d4604a95556b1d73be",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/product-owner.md",
+ "source": "extensions/opencode/agents/product-owner.md",
+ "content_hash": "8539983e7ccfe8b94947b9d135b742482be46687cf88b0bc9e84b32c3d4ae605",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/qa.md",
+ "source": "extensions/opencode/agents/qa.md",
+ "content_hash": "85c5ce3da470836f432cb8b565647e15b7813c6f3f6be4e694a068d092899e51",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/agents/team-lead.md",
+ "source": "extensions/opencode/agents/team-lead.md",
+ "content_hash": "53c046cf29b596ed85467d1110e35a8ba85c60195ba6ea9793f594815d68c4fd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/add-migration.md",
+ "source": "areas/software/backend/workflows/add-migration.md",
+ "content_hash": "bd44e8d554b221e3289e004ee6b917464a4351b651f154a2a103228be0c9917e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/backend-project-full-cycle.md",
+ "source": "areas/software/full-stack/workflows/backend-project-full-cycle.md",
+ "content_hash": "69cad926cfb58f576cb9a20503dcae96f21ece6f1c9751e11c888bcf29fd0e20",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/code-review-workflow.md",
+ "source": "areas/software/general/workflows/code-review-workflow.md",
+ "content_hash": "bd810a35f37c58827dc5de5bddb3306d74f225a2ad95cb647664683495233388",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/create-endpoint.md",
+ "source": "areas/software/backend/workflows/create-endpoint.md",
+ "content_hash": "63d7ff9030fb0ccfb4fbb1b0604f79fbd8a619b2bae4556e1249e5345bbfeae9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/debug-issue.md",
+ "source": "areas/software/full-stack/workflows/debug-issue.md",
+ "content_hash": "f881ddf3c181720dacef263b011466a92b6e904475a8df55e670b6ffcb647f6d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/develop-epic.md",
+ "source": "areas/software/backend/workflows/develop-epic.md",
+ "content_hash": "8395fbe6f51505d06a092454cba7653434564d2bfa06065c463baaf1756a4011",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/develop-feature.md",
+ "source": "areas/software/full-stack/workflows/develop-feature.md",
+ "content_hash": "4016cebec81741efc512c92c36e97381aaf37aa32bcad2b934788706e28152bf",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/development-cycle-workflow.md",
+ "source": "areas/software/general/workflows/development-cycle-workflow.md",
+ "content_hash": "46fa9677d5f328900b1c34f8516db0c1860d1044e970efb3cc7729d52c0ea4e9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/feature-implementation-flow.md",
+ "source": "areas/software/full-stack/workflows/feature-implementation-flow.md",
+ "content_hash": "d96aee0a614a96bc57af0ccd0f852d3cd6ac4025fc689183561514916b72277b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/feature.md",
+ "source": "extensions/opencode/commands/feature.md",
+ "content_hash": "6652d6778d25a083b97602cbc52eabe4138b2dae9ef7ddb0894e4b63555dc75f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/project-setup-workflow.md",
+ "source": "areas/software/general/workflows/project-setup-workflow.md",
+ "content_hash": "c35e4dc60033fce3a797da794df5dd249de7bfba07d28e2b8e24664a42586780",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/refactor-module.md",
+ "source": "areas/software/backend/workflows/refactor-module.md",
+ "content_hash": "a2037b7a5ddc65041647127f0b0cf1b312decf07711916ef993691a21f940a07",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/test-feature.md",
+ "source": "areas/software/backend/workflows/test-feature.md",
+ "content_hash": "d53995c3bda5f0f9b643615e104f4a19ff357be9cb3fa10c9909f431e77b75cf",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/commands/testing-ci-pipeline.md",
+ "source": "areas/software/full-stack/workflows/testing-ci-pipeline.md",
+ "content_hash": "b71c2ee351cc59f6ce7d49726a993f02f3ab32d1a873d272e7e4448f7fc0e66c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/opencode.json",
+ "source": "generated:mcp-opencode-legacy-config",
+ "content_hash": "07f831613b9df11a1488cd4161695d1963f572a3b32ac377d5f9315a4f862078",
+ "marker": "config",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/api-design-guide.md",
+ "source": "areas/software/full-stack/rules/api-design-guide.md",
+ "content_hash": "e81082b9cf8ccce5494198730f4956928f42d82a0abf4d1803fb92f53384924e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/architecture.md",
+ "source": "areas/software/backend/rules/architecture.md",
+ "content_hash": "c4b2729430da1c0f25fd15516b053befebc8d45600922a655fa5066d9044a304",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/async-concurrency-guide.md",
+ "source": "areas/software/full-stack/rules/async-concurrency-guide.md",
+ "content_hash": "750799b62b921f8d2b5faf6bca1780ce16c4fa763b677803fcea83e6e0460583",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/backend-architecture-rule.md",
+ "source": "areas/software/full-stack/rules/backend-architecture-rule.md",
+ "content_hash": "ba14c8cb24c52d9840f919c2a3fc4134d4cae99341706402e27aa80bf8ef9505",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/background-jobs-guide.md",
+ "source": "areas/software/full-stack/rules/background-jobs-guide.md",
+ "content_hash": "6ad7cbf99f62774ada1f711a305b8d9b9a83079bd681014d4eb172cdcfdbfc06",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/code-quality-guide.md",
+ "source": "areas/software/full-stack/rules/code-quality-guide.md",
+ "content_hash": "d6533e21854291f29ddbd2405083912b337367189af9511b3b3fe0af9d9d8209",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/code-style-guide.md",
+ "source": "areas/software/general/rules/code-style-guide.md",
+ "content_hash": "f49c486853820f32e0ecf928c5579146e0cd5be72594bd28279f8b94d3ebe1e4",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/data_access.md",
+ "source": "areas/software/backend/rules/data_access.md",
+ "content_hash": "b987f1eebe5d8b2279d4b85d43d732344ed6a1a43f8d9ecf27d9b0638e90c2e5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/database-access-guide.md",
+ "source": "areas/software/full-stack/rules/database-access-guide.md",
+ "content_hash": "a68da153877f7b079ff3cfd0f5cc27c9c23e11ca73f28682bcb1df04df881cbd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/database-migrations-guide.md",
+ "source": "areas/software/full-stack/rules/database-migrations-guide.md",
+ "content_hash": "7249c445cd5ad16ade437f7c02b0c111e700ba3995f69b37fa684b5ec35a9654",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/docker-compose-guide.md",
+ "source": "areas/software/general/rules/docker-compose-guide.md",
+ "content_hash": "12eeea14effbec3b8a33728de4c81b083c87458a73eae05bccd0a9813258a7fa",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/domain-models-guide.md",
+ "source": "areas/software/full-stack/rules/domain-models-guide.md",
+ "content_hash": "6de441308ceb5cd60d0ed6ae4b81d8d01ff6c53a7b874c0255dfbd7b847e3f36",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/e2e-test-guide.md",
+ "source": "areas/software/full-stack/rules/e2e-test-guide.md",
+ "content_hash": "774c28f425f2a73a5db65e4a0b509df4fc9912694dcfe0783253bfac6c5e6468",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/env-settings-guide.md",
+ "source": "areas/software/full-stack/rules/env-settings-guide.md",
+ "content_hash": "d295707c1ae334e1da2ccbcb945fa789f5f51b5d29bbd6d13d716031405400ba",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/error-handling-guide.md",
+ "source": "areas/software/full-stack/rules/error-handling-guide.md",
+ "content_hash": "6f0db1dc09e2b81fc42889f28a7c32863fa0b77ae63825faa09485bd0c1e7483",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/git-workflow-guide.md",
+ "source": "areas/software/general/rules/git-workflow-guide.md",
+ "content_hash": "bac22f1ef7d33d5f95185af77b835c6dd879ef2232484ee106171b47a5c0fd11",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/github-workflow-guide.md",
+ "source": "areas/software/general/rules/github-workflow-guide.md",
+ "content_hash": "e8fda1f0a076b83e1f78506ce54ed17b8b0ee382c61cabd802c93411ece17e75",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/gitlab-ci-guide.md",
+ "source": "areas/software/general/rules/gitlab-ci-guide.md",
+ "content_hash": "fc9bbaac01d84b0f517cc51b3ed3dc493fec68c7111d9bff6d7f69eabeebc48c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/lint-format-guide.md",
+ "source": "areas/software/general/rules/lint-format-guide.md",
+ "content_hash": "bd00ee10c90c54b5193a996df436faac46ae9d6a2bc2b201a927e15fc5e6262d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/logging-observability-guide.md",
+ "source": "areas/software/full-stack/rules/logging-observability-guide.md",
+ "content_hash": "53b75fdbf010f2735cd54ae349f8a5c3b08e348bb4112fc757bed19d59da6377",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/makefile-guide.md",
+ "source": "areas/software/general/rules/makefile-guide.md",
+ "content_hash": "91f24f2804112b03fb8fed7705d44050be7512e8352b4f438e788162e3ddc00a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/project-guide.md",
+ "source": "areas/software/full-stack/rules/project-guide.md",
+ "content_hash": "1b831d5528e915d0785b186159bffb99d2758e96d5fc99883d3e49885dbe74a0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/python-venv-guide.md",
+ "source": "areas/software/full-stack/rules/python-venv-guide.md",
+ "content_hash": "1827c76663aec2ffc7680475d60b582210ff406c127abff525e89b708ff8545d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/readme-sync-guide.md",
+ "source": "areas/software/general/rules/readme-sync-guide.md",
+ "content_hash": "5c7e39181194b34bfa4814e99ae665b079a1deef396b66f015285628a99b5d70",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/sdlc-methodology-guide.md",
+ "source": "areas/software/general/rules/sdlc-methodology-guide.md",
+ "content_hash": "09c9c996727224987a8f9fc07462ca5ca1921d3b399f0c0486a400cf619c7cf7",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/sdlc-role-responsibilities.md",
+ "source": "areas/software/general/rules/sdlc-role-responsibilities.md",
+ "content_hash": "43d4ea2fbff127213c25acba8d74051352bff4b0e0a9c327f02d4a6fc032657e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/security-guide.md",
+ "source": "areas/software/full-stack/rules/security-guide.md",
+ "content_hash": "035bab7f0cdeaebf7075a49609938e634be691336fe65a4474e8c78fe39c3b1c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/security.md",
+ "source": "areas/software/backend/rules/security.md",
+ "content_hash": "561cf9cd3285da477f1d4000712ccca100b902c4af2b93f6338fd899d8eaabe3",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/svt-test-guide.md",
+ "source": "areas/software/full-stack/rules/svt-test-guide.md",
+ "content_hash": "8d0d28a84723a4a9da402cb6f16c80c1d31817be749f886ec6ccccb62a46043f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/testing-ci-guide.md",
+ "source": "areas/software/full-stack/rules/testing-ci-guide.md",
+ "content_hash": "be133361dde8587eb573b6ad8e3f51124a90f59edb900eee4e8b7b25aed94ab6",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/rules/testing.md",
+ "source": "areas/software/backend/rules/testing.md",
+ "content_hash": "2d97a5a75323e49affa2e8d62cc5a2db0d609c35538f0efa08b479115fa5671b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/SKILL.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/SKILL.md",
+ "content_hash": "3886ab15e191ff89653b8c0c21321deb94aef2e1f07f520175cffab941456c07",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/assets/api-design-checklist.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/assets/api-design-checklist.md",
+ "content_hash": "53569577e2ff0370fd5db5e33b40efc88da311f2dc31b1c8c94caaba37ec1c0a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/assets/rest-api-template.py",
+ "source": "areas/software/full-stack/skills/api-design-principles/assets/rest-api-template.py",
+ "content_hash": "e4c51f4dbad9c5c2f02a37bcf2ad9b560300c1d3959188d4d76124d220b90f51",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/references/graphql-schema-design.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/references/graphql-schema-design.md",
+ "content_hash": "3b0e2ed56500d466ef3939c9e1bd21bea125fd15ef8a2c8eedfa9ab219e8e1a4",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/references/rest-best-practices.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/references/rest-best-practices.md",
+ "content_hash": "250d817f2f83f2d0c48d30a0a58fd2f1adb253b474faca23eae59f2c89fc8b51",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design-principles/resources/implementation-playbook.md",
+ "source": "areas/software/full-stack/skills/api-design-principles/resources/implementation-playbook.md",
+ "content_hash": "dda495da043206d8d2467b9e815de489a64a3e295100b67f7656194b0da05c00",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-design/SKILL.md",
+ "source": "areas/software/backend/skills/api-design/SKILL.md",
+ "content_hash": "ac92f61337167053ceb843584807c8c1678a765397cb97e7838779d9344f6fec",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/SKILL.md",
+ "source": "areas/software/full-stack/skills/api-patterns/SKILL.md",
+ "content_hash": "1e3b22e8ae8866dc16b3fedd30c2bc7ff201ac7b27562e9b855fa68faef64347",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/api-style.md",
+ "source": "areas/software/full-stack/skills/api-patterns/api-style.md",
+ "content_hash": "2093c21d41fc04e83a163936ef33481fb95c07f740edb46d322eb125a2fc00b0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/auth.md",
+ "source": "areas/software/full-stack/skills/api-patterns/auth.md",
+ "content_hash": "23a6c70e2036cacb221a8470654aab3208d6415fbb425bb7926788bedfd76d30",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/documentation.md",
+ "source": "areas/software/full-stack/skills/api-patterns/documentation.md",
+ "content_hash": "1884bdead17689106ce21124aae5a78aae723a1d140623ff6b86b1b113e2d153",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/graphql.md",
+ "source": "areas/software/full-stack/skills/api-patterns/graphql.md",
+ "content_hash": "4ca8a7344e70081f9674a40630b1ce72530578a1de0b4bf0ed0c15c0daf9d6f0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/rate-limiting.md",
+ "source": "areas/software/full-stack/skills/api-patterns/rate-limiting.md",
+ "content_hash": "749e4959b24be1b52a1afe118c5d07057e78e665d2681c379fbf5659902fbb70",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/response.md",
+ "source": "areas/software/full-stack/skills/api-patterns/response.md",
+ "content_hash": "d4a8f52b17be4f39390b52d3becd5de9ee2a114fbfa32f9d1beba16f07281e72",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/rest.md",
+ "source": "areas/software/full-stack/skills/api-patterns/rest.md",
+ "content_hash": "906cecb6ed1385c5fe00799f593302a4ed8395895c89925f65b96a93922f3004",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/scripts/api_validator.py",
+ "source": "areas/software/full-stack/skills/api-patterns/scripts/api_validator.py",
+ "content_hash": "c09e803b9bc62bdd833d37925b8643980e0d56dd6aad180d3b6cf6c3b0889349",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/security-testing.md",
+ "source": "areas/software/full-stack/skills/api-patterns/security-testing.md",
+ "content_hash": "67c06db63aa0431c49fa30333960b8f79fbe55c6a7848708d9c885d8b2d0649f",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/trpc.md",
+ "source": "areas/software/full-stack/skills/api-patterns/trpc.md",
+ "content_hash": "a1746ce93139ab250eff2684f721c6be3d5f9cd6f7ea3da075174baeaf449859",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/api-patterns/versioning.md",
+ "source": "areas/software/full-stack/skills/api-patterns/versioning.md",
+ "content_hash": "250e095d07a2f1a2537d734b99de9db2baa71428bb4ee62470d87b795590abb0",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/SKILL.md",
+ "source": "areas/software/full-stack/skills/app-builder/SKILL.md",
+ "content_hash": "fa54cd8fb8400b50285868c01cc139a7052696a39fab9717d49b5b28585e250c",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/agent-coordination.md",
+ "source": "areas/software/full-stack/skills/app-builder/agent-coordination.md",
+ "content_hash": "8f5476c510699dec80066506dd4b3e6d90cecd45631fb3919eb355ffb0cb8705",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/feature-building.md",
+ "source": "areas/software/full-stack/skills/app-builder/feature-building.md",
+ "content_hash": "35f6753183bc23157042c9d2d93366c9bf2b7ebb95e3ef38465dbcb1c0ec89e3",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/project-detection.md",
+ "source": "areas/software/full-stack/skills/app-builder/project-detection.md",
+ "content_hash": "0f991848a61bc35fa53987596925516916e3e9448be5d9a31e9d1aad8182de53",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/scaffolding.md",
+ "source": "areas/software/full-stack/skills/app-builder/scaffolding.md",
+ "content_hash": "4e8a4878fb576b72fd6b599b03ba3cbd8271291cb15180ebce6cfcd714fddb84",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/tech-stack.md",
+ "source": "areas/software/full-stack/skills/app-builder/tech-stack.md",
+ "content_hash": "fbd9f1deee8bd161d14cea7c9b66cff6d4ca13e285257e965b28b8fdfcb243f5",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/SKILL.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/SKILL.md",
+ "content_hash": "cfb041ad2818b92bd85bb430296062f1b14e3769cc4c719051a4f138d5089bbd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/astro-static/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/astro-static/TEMPLATE.md",
+ "content_hash": "58fe2d96ec7f710eaf9c8b595982328ef7d6fa9c8c5174985b588d1baccdab5d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/chrome-extension/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/chrome-extension/TEMPLATE.md",
+ "content_hash": "d5b32b9cbfb6d23c4b1ec897c1177f3eab1a622d976d3fd4f01def83e0659f03",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/cli-tool/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/cli-tool/TEMPLATE.md",
+ "content_hash": "7a3f410855a9d2f302c166ac0827fea760535a0b5780561a66d5ead8d2a7dc89",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/electron-desktop/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/electron-desktop/TEMPLATE.md",
+ "content_hash": "4b3b2ac3938ae06786162795c803340ee139306fb8bb613187bf7d9213de7c74",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/express-api/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/express-api/TEMPLATE.md",
+ "content_hash": "a42a0d3ddf21ab1a5d1d3b10858d1f354477cba873a523a4531d4af876efe8a7",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/flutter-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/flutter-app/TEMPLATE.md",
+ "content_hash": "a0aa2e6118e9364f4d5225d3b51433f1854f2195bf6f1097b76ec116975b58ef",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/monorepo-turborepo/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/monorepo-turborepo/TEMPLATE.md",
+ "content_hash": "5f41699fa098e10b116852a62db66ea79eba6f2f23db5902c346402f99836c22",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/nextjs-fullstack/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-fullstack/TEMPLATE.md",
+ "content_hash": "4054efc0f42c3da874dc0d2f69ebfa918e7b08096fbf9e9feac098a5390325a9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/nextjs-saas/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-saas/TEMPLATE.md",
+ "content_hash": "1c37021e2736d5efd8ca6f58903f9398faeef6ee0b2ebdbd29793644ab2ed978",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/nextjs-static/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nextjs-static/TEMPLATE.md",
+ "content_hash": "2cccd2d59b1df289885e08f927b1f46bc478ab8282014773bb811738a762302b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/nuxt-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/nuxt-app/TEMPLATE.md",
+ "content_hash": "ba87406e7774635b359e3831f8952d01bdd8e2ee8eb7a8989794451f46a96ca6",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/python-fastapi/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/python-fastapi/TEMPLATE.md",
+ "content_hash": "196df34b055a7bd7fde05e9f90a94b55d5a6077afb3af34ab9b19ad778ea2bcb",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/app-builder/templates/react-native-app/TEMPLATE.md",
+ "source": "areas/software/full-stack/skills/app-builder/templates/react-native-app/TEMPLATE.md",
+ "content_hash": "d24c1793e238d765f69e45e1fc40514647f1697169600941b6dd4ea61f738061",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/async-processing/SKILL.md",
+ "source": "areas/software/backend/skills/async-processing/SKILL.md",
+ "content_hash": "8496f7a391065a29d836eed1e28ce8b7abf5333608bbfded66f0d63159e845b2",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/backend-developer/SKILL.md",
+ "source": "areas/software/full-stack/skills/backend-developer/SKILL.md",
+ "content_hash": "bde94a119fa5ad8c9b125a62997db1e6bc9b80e0218a08ddbd1b7fb298047c14",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/blackbox-test/SKILL.md",
+ "source": "areas/software/full-stack/skills/blackbox-test/SKILL.md",
+ "content_hash": "ae4a8b6f273b6004984ba4e50a1d789c743a04af9b66c7c7c371dd8f5cb1bcf2",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/database-modeling/SKILL.md",
+ "source": "areas/software/backend/skills/database-modeling/SKILL.md",
+ "content_hash": "d456fcc53f1e25a28650be5a44518f36354c8d1e0d7feae8e8d12d85675b552d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/general-dev-tools/SKILL.md",
+ "source": "areas/software/general/skills/general-dev-tools/SKILL.md",
+ "content_hash": "8ac088f3856a7605a7dc31a974ccfe9cb3481e4c708162f0d0bb3fc2ae113d8a",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/observability/SKILL.md",
+ "source": "areas/software/backend/skills/observability/SKILL.md",
+ "content_hash": "09cd27911a474ca248db5dd7f0e647c774f630529d987de5d15a08c88efc18cd",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/prompt-project-planner/SKILL.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/SKILL.md",
+ "content_hash": "1a3e708f8ef6ff872a714394d6ca89cb2be7fa4d50860f5308b218bce1bcc35e",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/prompt-project-planner/output.schema.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/output.schema.md",
+ "content_hash": "18fbf01444d282c51392919dd0084e33564a40480d3f38015cddfab6bf513c6b",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/prompt-project-planner/questions.md",
+ "source": "areas/software/full-stack/skills/prompt-project-planner/questions.md",
+ "content_hash": "902d03ff6cb3c49cfe16cb14042747d4b102c7f4deec36daa403f9b435e0bb40",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": ".opencode/skills/troubleshooting/SKILL.md",
+ "source": "areas/software/backend/skills/troubleshooting/SKILL.md",
+ "content_hash": "9a5827f647eb6d0d1acb42d92d1896f9188d95f06ae45ef7fec3b89ac9af4e6d",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": "AGENTS.md",
+ "source": "generated:AGENTS.md",
+ "content_hash": "56fc18030e1a0295f5ee21f3c849a824d372a309f7adfe34035647805261d7a9",
+ "marker": "internal",
+ "updated_at": "2026-06-20T17:39:52Z"
+ },
+ {
+ "path": "opencode.json",
+ "source": "generated:mcp-opencode-config",
+ "content_hash": "1626b98e534c52926383c25278a618bfd4d8a15af90ee14daae40176afd406e7",
+ "marker": "config",
+ "updated_at": "2026-06-20T17:39:52Z"
+ }
+ ],
+ "skipped_files": []
+}
diff --git a/.config/opencode/AGENTS.md b/.config/opencode/AGENTS.md
old mode 100644
new mode 100755
index 62fd91b..ec12afe
--- a/.config/opencode/AGENTS.md
+++ b/.config/opencode/AGENTS.md
@@ -1,134 +1,320 @@
-# Agent Guidelines for this Repository
+---
+agentic:
+ generated_by: agentic
+ source: "generated:AGENTS.md"
+ repository: "https://github.com/sawrus/agent-guides"
+ created_by: "v0.4.0"
+ updated_by: "v0.4.0"
+---
+# Agentic Project Guidelines
-This document outlines the conventions and commands for agents operating within this repository.
+Generated by agentic.
-## 1. Build/Lint/Test Commands
+## Installation Context
+- Agent OS targets: opencode
+- Primary agent rules directory: .opencode/rules
+- Areas: software
+- Specializations: software.backend software.full-stack software.general
-- **Build:** No specific build command found. Please refer to project-specific documentation or infer from file types.
-- **Lint:** No specific lint command found. Please refer to project-specific documentation or infer from file types.
-- **Test:** No specific test command found. To run a single test, you may need to use a test runner specific to the language/framework (e.g., `pytest path/to/test_file.py::test_name` for Python, `npm test -- path/to/test_file.js` for JavaScript).
+---
-## 2. Code Style Guidelines
+## Shared guidance
-No explicit code style guidelines (e.g., `.eslintrc.js`, `prettier.config.js`, `ruff.toml`) were found in this repository. Agents should adhere to the following general principles:
+# AGENTS — root guidance
-- **Imports:** Organize imports consistently (e.g., alphabetical, grouped by type).
-- **Formatting:** Maintain consistent indentation (spaces/tabs), line endings, and brace style.
-- **Types:** Use type annotations where supported and beneficial for clarity and maintainability.
-- **Naming Conventions:** Follow established naming conventions for variables, functions, classes, and files (e.g., `camelCase`, `snake_case`, `PascalCase`).
-- **Error Handling:** Implement robust error handling mechanisms, logging errors appropriately.
+## Dynamic guidance loading
-## Existing Agent Rules
+The loaded guidance set is project-specific and may change per task. Do not assume the statically listed files are
+complete; first discover optional guidance under the target project's `.agent/` directory.
-You are dullx, a master-level AI prompt optimization specialist. Your mission: transform any user input into precision-crafted prompts that unlock AI's full potential across all platforms.
+Discover and load guidance in this order:
-## THE 4-D METHODOLOGY
+1. `.agent/*.md` — project baseline files, when present
+2. `.agent/rules/*.md` — all project rules
+3. `.agent/skills/*/SKILL.md` — only the skill matching the current task
+4. `.agent/workflows/*.md` — only the workflow matching the triggered command
+5. `.agent/prompts/*.md` — only when explicitly requested or referenced by loaded guidance
-### 1. DECONSTRUCT
+Prefer relative paths in references inside markdown files.
-- Extract core intent, key entities, and context
-- Identify output requirements and constraints
-- Map what's provided vs. what's missing
+---
-### 2. DIAGNOSE
+## General Development Practices
-- Audit for clarity gaps and ambiguity
-- Check specificity and completeness
-- Assess structure and complexity needs
+Cross-cutting practices that apply to every project regardless of area.
-### 3. DEVELOP
+### Git Workflow
-- Select optimal techniques based on request type:
- - **Creative** → Multi-perspective + tone emphasis
- - **Technical** → Constraint-based + precision focus
- - **Educational** → Few-shot examples + clear structure
- - **Complex** → Chain-of-thought + systematic frameworks
-- Assign appropriate AI role/expertise
-- Enhance context and implement logical structure
+- Use feature branches with task IDs in branch names (e.g., `feat/TASK-123-add-auth`).
+- Commit messages include context: what changed, why, and any relevant ticket reference.
+- No direct commits to main or protected branches — all changes via reviewed PRs.
-### 4. DELIVER
+### Makefile Conventions
-- Construct optimized prompt
-- Format based on complexity
-- Provide implementation guidance
+- Use Makefile for common development tasks accessible to all contributors.
+- Include a `help` target listing available commands with descriptions.
+- Standard targets: `install`, `dev`, `test`, `lint`, `fmt`, `clean`, `build`.
-## OPTIMIZATION TECHNIQUES
+### Docker Compose
-**Foundation:** Role assignment, context layering, output specs, task decomposition
+- Use docker-compose for local multi-service development.
+- Configure health checks for all dependent services before marking ready.
+- Drive configuration via environment variables; no hardcoded values.
-**Advanced:** Chain-of-thought, few-shot learning, multi-perspective analysis, constraint optimization
+### Linting and Formatting
-**Platform Notes:**
+- Configure language-appropriate linters for every project.
+- Enforce standards via pre-commit hooks — CI is a safety net, not the primary check.
+- Apply consistent formatting across all files on every commit.
-- **ChatGPT/GPT-4:** Structured sections, conversation starters
-- **Claude:** Longer context, reasoning frameworks
-- **Gemini:** Creative tasks, comparative analysis
-- **Others:** Apply universal best practices
+### SDLC Methodology
-## OPERATING MODES
+- Follow phases in order: Requirements → Design → Implementation → Verification → Deployment → Maintenance.
+- Document requirements before implementation — "we'll figure it out" is not a requirement.
+- Conduct design reviews for any change with architectural, security, or data model impact.
+- Before changing project logic, read the relevant design and behavior documents under `docs/**`.
+- Run validation, tests, coverage, and CI-style checks through Makefile targets only.
-**DETAIL MODE:**
+### Documentation of Behavior Changes
-- Gather context with smart defaults
-- Ask 2-3 targeted clarifying questions
-- Provide comprehensive optimization
+- Any behavior change captured in Markdown artifacts must be documented under the project `docs/` directory.
+- Use documentation paths that match the change type, for example `docs//README.md` for feature behavior and
+ `docs/incidents/--root-cause.md` for incident root cause reports.
+- Create or update the relevant `docs/` artifact in the same change set; do not leave behavior changes documented only
+ in workflow outputs, tickets, or PR comments.
+- Apply the `product-owner` role to confirm that docs describe the user-facing behavior, acceptance criteria, and
+ operational constraints of the change.
-**BASIC MODE:**
+### Versioning And Changelog
-- Quick fix primary issues
-- Apply core techniques only
-- Deliver ready-to-use prompt
+- After every completed feature, update the project version in the appropriate version source for the repository.
+- Update `CHANGELOG.md` in the same change set with a concise user-facing summary, operational notes, and any migration
+ or rollout considerations.
+- Do not leave feature behavior documented only in implementation notes, rollout logs, or PR text; the version and
+ changelog are part of the feature completion contract.
-## RESPONSE FORMATS
+### MCP Memory Providers
-**Simple Requests:**
+See [MEMORY.md](MEMORY.md) for the full protocol: provider roles, Context7 usage, MemPalace session-start queries,
+fact-writing triggers, tool call examples, and fallback order.
-```
-**Your Optimized Prompt:**
-[Improved prompt]
+### Code Style
-**What Changed:** [Key improvements]
-```
+- Write self-documenting code with meaningful names — comments explain why, not what.
+- Apply DRY principles; avoid duplication across modules.
+- Keep functions focused on a single responsibility; extract when a function does two things.
-**Complex Requests:**
+---
-```
-**Your Optimized Prompt:**
-[Improved prompt]
+## Repository Exploration (mandatory before implementation)
+
+Before executing any workflow, spawn a subagent to explore the repository.
+
+### Role resolution
+
+- Use the role defined in `execution.initiator` of the current workflow.
+- If not defined, use the role assigned to the first workflow step.
+- Resolve roles only from the current workflow's `roles` section — do not invent roles.
+
+### Purpose
+
+- Understand current architecture and established conventions.
+- Identify key modules, entrypoints, and data flows.
+- Detect configuration (env, Docker, Makefile, CI).
+- Identify components impacted by the proposed change.
+- Validate feasibility before any code is written.
+
+### Subagent responsibilities
+
+- Analyze repository structure and dependencies.
+- Locate relevant services, modules, and data flows.
+- Identify constraints, risks, and integration points.
+- Prepare a context summary for the next workflow step.
+
+### Required output
+
+- High-level system overview.
+- List of affected modules and files.
+- Detected risks and constraints.
+
+### Constraint
+
+This step is **mandatory** and must complete before "Solution Design & Risk Plan" begins.
-**Key Improvements:**
-• [Primary changes and benefits]
+---
-**Techniques Applied:** [Brief mention]
+## software/backend
-**Pro Tip:** [Usage guidance]
+# Backend — guidance index
+
+## What this area covers
+
+Server-side service development: REST / GraphQL API design, domain modeling, database access patterns, async processing, observability, and security. Load after `software/general/` baseline.
+
+## Inherited from general
+
+- SDLC methodology and role responsibilities
+- Git / CI / lint / format and code style baselines
+- General development and code review workflows
+
+## Backend-specific overrides
+
+- All endpoints must include authZ check, input validation, and structured error response.
+- Database changes require a migration file — no schema drift via ORM sync in production.
+- Every new module requires observability: at minimum, structured logs and a latency metric.
+
+## Spec map
+
+```text
+.agent/
+├── rules/
+│ ├── architecture.md ↠layering, module boundaries, dependency direction
+│ ├── data_access.md ↠ORM usage, query patterns, N+1 prevention
+│ ├── security.md ↠authN/authZ, input validation, secret handling
+│ └── testing.md ↠test pyramid targets, mock boundaries, contract tests
+├── skills/
+│ ├── api-design/SKILL.md ↠REST / GraphQL conventions, versioning, contracts
+│ ├── async-processing/SKILL.md ↠queues, workers, retry/DLQ patterns
+│ ├── database-modeling/SKILL.md ↠schema design, indexes, migration safety
+│ ├── observability/SKILL.md ↠structured logging, metrics, distributed tracing
+│ └── troubleshooting/SKILL.md ↠systematic debugging, profiling, root-cause analysis
+├── workflows/
+│ ├── add-migration.md ↠/add-migration
+│ ├── create-endpoint.md ↠/create-endpoint
+│ ├── debug-issue.md ↠/debug-issue
+│ ├── develop-epic.md ↠/develop-epic
+│ ├── develop-feature.md ↠/develop-feature
+│ ├── refactor-module.md ↠/refactor-module
+│ └── test-feature.md ↠/test-feature
+└── prompts/
+ └── *.md
+```
+
+---
+
+## software/full-stack
+
+# Full Stack — guidance index
+
+## What this area covers
+
+End-to-end product feature development spanning backend services and frontend interfaces: API design, backend architecture, database access, async processing, frontend integration, testing pipelines, and full project lifecycle management.
+
+## Inherited from general
+
+- SDLC methodology, role responsibilities, and handoff contracts
+- Git / CI / lint / format and code style baselines
+- General development and code review workflows
+
+## Full-stack-specific constraints
+
+- API contracts are versioned and documented before implementation; no breaking changes without a migration path.
+- Frontend and backend changes for the same feature ship together in a coordinated, backward-compatible rollout.
+- Every new feature includes end-to-end tests covering at least the critical user path.
+
+## Spec map
+
+```text
+.agent/
+├── rules/
+│ ├── api-design-guide.md ↠REST/GraphQL/tRPC conventions, versioning
+│ ├── backend-architecture-rule.md ↠layering, module boundaries, DI
+│ ├── database-access-guide.md ↠ORM patterns, query safety, N+1 prevention
+│ ├── database-migrations-guide.md ↠migration safety, backward compatibility
+│ ├── async-concurrency-guide.md ↠queues, workers, deadlock prevention
+│ ├── background-jobs-guide.md ↠job design, retry, DLQ
+│ ├── error-handling-guide.md ↠error taxonomy, propagation, user messaging
+│ ├── logging-observability-guide.md ↠structured logs, trace IDs, metrics
+│ ├── security-guide.md ↠authN/authZ, input validation, secret handling
+│ ├── testing-ci-guide.md ↠test pyramid, CI checks, coverage thresholds
+│ ├── e2e-test-guide.md ↠e2e tool setup, test scope, flakiness policy
+│ ├── code-quality-guide.md ↠naming, DRY, single responsibility
+│ ├── env-settings-guide.md ↠environment config, secrets, local setup
+│ ├── domain-models-guide.md ↠entity design, value objects, aggregates
+│ └── project-guide.md ↠folder structure, module boundaries, tech stack
+├── skills/
+│ ├── api-design-principles/SKILL.md ↠REST best practices, GraphQL schema design
+│ ├── api-patterns/SKILL.md ↠auth, rate limiting, versioning, tRPC
+│ ├── app-builder/SKILL.md ↠project scaffolding, templates, tech stack detection
+│ ├── backend-developer/SKILL.md ↠service patterns, DI, repository design
+│ ├── blackbox-test/SKILL.md ↠external API testing, contract validation
+│ └── prompt-project-planner/SKILL.md ↠project planning, milestone scoping
+├── workflows/
+│ ├── develop-feature.md ↠/develop-feature
+│ ├── debug-issue.md ↠/debug-issue
+│ ├── backend-project-full-cycle.md ↠/backend-project-full-cycle
+│ ├── feature-implementation-flow.md ↠/feature-implementation-flow
+│ └── testing-ci-pipeline.md ↠/testing-ci-pipeline
+└── prompts/
+ └── *.md
```
-## WELCOME MESSAGE (REQUIRED)
+---
-When activated, display EXACTLY:
+## software/general
-"Hello! I'm dullx, your AI prompt optimizer. I transform vague requests into precise, effective prompts that deliver better results.
+# Software — general guidance index
-**What I need to know:**
+This area contains the shared SDLC baseline inherited by all software specialization areas. Load this before any domain-specific guidance.
-- **Target AI:** ChatGPT, Claude, Gemini, or Other
-- **Prompt Style:** DETAIL (I'll ask clarifying questions first) or BASIC (quick optimization)
+## What this area covers
-**Examples:**
+Cross-cutting engineering practices that apply regardless of stack: Git workflow, CI/CD standards, linting, code style, SDLC methodology, role responsibilities, and project setup conventions. Every software specialization area inherits from here and adds only domain-specific overrides.
-- "DETAIL using ChatGPT — Write me a marketing email"
-- "BASIC using Claude — Help with my resume"
+## Inheritance contract
-Just share your rough prompt and I'll handle the optimization!"
+All specialization areas follow this load order:
-## PROCESSING FLOW
+```
+AGENTS.md (scope) → rules (constraints) → skills (execution patterns) → workflows (orchestration)
+```
+
+Specializations reference general guidance and keep only domain-specific overrides. Do not duplicate general rules in domain-level files.
+
+## Spec selection
+
+| Task type | Area to load |
+|:---|:---|
+| API / service development | `software/backend/` |
+| UI / component development | `software/frontend/` |
+| Full product feature (API + UI) | `software/full-stack/` |
+| Data pipelines, dbt, warehouses | `software/data-engineering/` |
+| ML training, evaluation, deployment | `software/mlops/` |
+| iOS / Android / React Native | `software/mobile/` |
+| Infrastructure, K8s, CI/CD, incidents | `software/platform/` |
+| Test strategy, coverage, QA tooling | `software/qa/` |
+| Security scans, threat modeling | `software/security/` |
+| Cross-cutting / unclear domain | `software/general/` |
+
+## Cross-cutting constraints
+
+- **Git discipline** — every change lives in a branch; no direct commits to main.
+- **Lint and format** — all files pass configured linters before any handoff.
+- **SDLC role separation** — no role consolidation when subagent execution is required.
+- **README sync** — public-facing READMEs updated whenever behavior or setup changes.
+
+## Guidance tree
+
+```text
+.agent/
+├── rules/
+│ ├── git-workflow-guide.md ↠branching, commit messages, PR conventions
+│ ├── github-workflow-guide.md ↠GitHub-specific CI triggers and branch protection
+│ ├── gitlab-ci-guide.md ↠GitLab CI pipeline conventions
+│ ├── makefile-guide.md ↠standard Makefile targets and conventions
+│ ├── docker-compose-guide.md ↠local multi-service development setup
+│ ├── lint-format-guide.md ↠linter config and pre-commit hooks
+│ ├── sdlc-methodology-guide.md ↠phase definitions, entry/exit criteria
+│ ├── sdlc-role-responsibilities.md ↠role matrix, handoff contracts, DoD
+│ ├── readme-sync-guide.md ↠what READMEs must contain and when to update
+│ └── code-style-guide.md ↠naming, structure, DRY, single responsibility
+├── skills/
+│ └── general-dev-tools/SKILL.md ↠cross-stack dev tooling patterns
+└── workflows/
+ ├── project-setup-workflow.md ↠/project-setup
+ ├── code-review-workflow.md ↠/code-review
+ └── development-cycle-workflow.md ↠/dev-cycle
+```
-1. Auto-detect complexity:
- - Simple tasks → BASIC mode
- - Complex/professional → DETAIL mode
-2. Inform user with override option
-3. Execute chosen mode protocol
-4. Deliver optimized prompt
+---
-**Memory Note:** Do not save any information from optimization sessions to memory.
diff --git a/.config/opencode/opencode.json b/.config/opencode/opencode.json
old mode 100644
new mode 100755
index a29d038..18b9439
--- a/.config/opencode/opencode.json
+++ b/.config/opencode/opencode.json
@@ -2,17 +2,28 @@
"$schema": "https://opencode.ai/config.json",
"mcp": {
"github": {
- "enabled": false,
- "type": "local",
- "command": ["bunx", "-y", "@modelcontextprotocol/server-github"],
"enabled": true,
+ "type": "local",
+ "command": [
+ "bunx",
+ "-y",
+ "@modelcontextprotocol/server-github"
+ ],
"environment": {
- "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_PERSONAL_ACCESS_TOKEN}"
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:$GITHUB_PERSONAL_ACCESS_TOKEN}"
}
+ },
+ "opencode": {
+ "type": "local",
+ "command": [
+ "npx",
+ "-y",
+ "opencode-docs-mcp"
+ ],
+ "enabled": true
}
},
- "theme": "lucent-orng",
"default_agent": "plan",
"autoshare": false,
- "autoupdate": "notify"
+ "autoupdate": false
}
diff --git a/.config/opencode/opencode.json.tui-migration.bak b/.config/opencode/opencode.json.tui-migration.bak
new file mode 100644
index 0000000..a29d038
--- /dev/null
+++ b/.config/opencode/opencode.json.tui-migration.bak
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "github": {
+ "enabled": false,
+ "type": "local",
+ "command": ["bunx", "-y", "@modelcontextprotocol/server-github"],
+ "enabled": true,
+ "environment": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ }
+ },
+ "theme": "lucent-orng",
+ "default_agent": "plan",
+ "autoshare": false,
+ "autoupdate": "notify"
+}
diff --git a/.config/opencode/package-lock.json b/.config/opencode/package-lock.json
new file mode 100644
index 0000000..70c7635
--- /dev/null
+++ b/.config/opencode/package-lock.json
@@ -0,0 +1,774 @@
+{
+ "name": "opencode",
+ "version": "file:../../dot/.config/opencode",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@opencode-ai/plugin": "1.14.46"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "../../dot/.config/opencode/node_modules/@opencode-ai/plugin": {
+ "version": "1.14.46",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.46.tgz",
+ "integrity": "sha512-JXV0h/dMFGVmEvlKSrbkMeiiIafwWlAGTbAQ4KaK1Bn5yWCFx5DkYVcS3BvgebdJEPkQ4tjVKmG/LHjfZwbjkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@opencode-ai/sdk": "1.14.46",
+ "effect": "4.0.0-beta.59",
+ "zod": "4.1.8"
+ },
+ "peerDependencies": {
+ "@opentui/core": ">=0.2.6",
+ "@opentui/keymap": ">=0.2.6",
+ "@opentui/solid": ">=0.2.6"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/keymap": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
+ }
+ },
+ "../../dot/.config/opencode/node_modules/@opencode-ai/sdk": {
+ "version": "1.14.46",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
+ "integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "7.0.6"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "../../dot/.config/opencode/node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/effect": {
+ "version": "4.0.0-beta.59",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
+ "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "fast-check": "^4.6.0",
+ "find-my-way-ts": "^0.1.6",
+ "ini": "^6.0.0",
+ "kubernetes-types": "^1.30.0",
+ "msgpackr": "^1.11.9",
+ "multipasta": "^0.2.7",
+ "toml": "^4.1.1",
+ "uuid": "^13.0.0",
+ "yaml": "^2.8.3"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/fast-check": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
+ "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=12.17.0"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/find-my-way-ts": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
+ "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
+ "license": "MIT"
+ },
+ "../../dot/.config/opencode/node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "../../dot/.config/opencode/node_modules/kubernetes-types": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
+ "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
+ "license": "Apache-2.0"
+ },
+ "../../dot/.config/opencode/node_modules/msgpackr": {
+ "version": "1.11.12",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
+ "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/multipasta": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
+ "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
+ "license": "MIT"
+ },
+ "../../dot/.config/opencode/node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/pure-rand": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
+ "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "../../dot/.config/opencode/node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/toml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
+ "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/uuid": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
+ "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/yaml": {
+ "version": "2.8.4",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
+ "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "../../dot/.config/opencode/node_modules/zod": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
+ "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "darwin"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "darwin"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "linux"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "extraneous": true,
+ "license": "MIT",
+ "os": [
+ "win32"
+ ]
+ },
+ "../../dot/dot/.config/opencode/node_modules/@opencode-ai/plugin": {
+ "version": "1.14.46",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.46.tgz",
+ "integrity": "sha512-JXV0h/dMFGVmEvlKSrbkMeiiIafwWlAGTbAQ4KaK1Bn5yWCFx5DkYVcS3BvgebdJEPkQ4tjVKmG/LHjfZwbjkA==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "@opencode-ai/sdk": "1.14.46",
+ "effect": "4.0.0-beta.59",
+ "zod": "4.1.8"
+ },
+ "peerDependencies": {
+ "@opentui/core": ">=0.2.6",
+ "@opentui/keymap": ">=0.2.6",
+ "@opentui/solid": ">=0.2.6"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/keymap": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/@opencode-ai/sdk": {
+ "version": "1.14.46",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
+ "integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "7.0.6"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "extraneous": true,
+ "license": "MIT"
+ },
+ "../../dot/dot/.config/opencode/node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "extraneous": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/effect": {
+ "version": "4.0.0-beta.59",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
+ "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "fast-check": "^4.6.0",
+ "find-my-way-ts": "^0.1.6",
+ "ini": "^6.0.0",
+ "kubernetes-types": "^1.30.0",
+ "msgpackr": "^1.11.9",
+ "multipasta": "^0.2.7",
+ "toml": "^4.1.1",
+ "uuid": "^13.0.0",
+ "yaml": "^2.8.3"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/fast-check": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
+ "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
+ "extraneous": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=12.17.0"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/find-my-way-ts": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
+ "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
+ "extraneous": true,
+ "license": "MIT"
+ },
+ "../../dot/dot/.config/opencode/node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "extraneous": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "extraneous": true,
+ "license": "ISC"
+ },
+ "../../dot/dot/.config/opencode/node_modules/kubernetes-types": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
+ "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
+ "extraneous": true,
+ "license": "Apache-2.0"
+ },
+ "../../dot/dot/.config/opencode/node_modules/msgpackr": {
+ "version": "1.11.12",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
+ "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
+ "extraneous": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "extraneous": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/multipasta": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
+ "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
+ "extraneous": true,
+ "license": "MIT"
+ },
+ "../../dot/dot/.config/opencode/node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "extraneous": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/pure-rand": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
+ "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
+ "extraneous": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "../../dot/dot/.config/opencode/node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "extraneous": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/toml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
+ "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
+ "extraneous": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/uuid": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
+ "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
+ "extraneous": true,
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "extraneous": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/yaml": {
+ "version": "2.8.4",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
+ "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+ "extraneous": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "../../dot/dot/.config/opencode/node_modules/zod": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
+ "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
+ "extraneous": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/.config/opencode/plugins/herdr-agent-state.js b/.config/opencode/plugins/herdr-agent-state.js
new file mode 100644
index 0000000..3c2eb60
--- /dev/null
+++ b/.config/opencode/plugins/herdr-agent-state.js
@@ -0,0 +1,159 @@
+ // installed by herdr
+ // managed by herdr; reinstalling or updating the integration overwrites this file.
+ // add custom hooks/plugins beside this file instead of editing it.
+ // HERDR_INTEGRATION_ID=opencode
+// HERDR_INTEGRATION_VERSION=5
+
+ import net from "node:net";
+
+ const SOURCE = "herdr:opencode";
+const AGENT = "opencode";
+ let reportSeq = Date.now() * 1000;
+
+ function nextReportSeq() {
+ reportSeq += 1;
+ return reportSeq;
+ }
+
+ function sessionIDFromProperties(properties) {
+ return typeof properties?.sessionID === "string" && properties.sessionID
+ ? properties.sessionID
+ : undefined;
+ }
+
+function stateFromSessionStatus(status) {
+ if (typeof status !== "string") {
+ return undefined;
+ }
+ switch (status.toLowerCase()) {
+ case "idle":
+ return "idle";
+ case "active":
+ case "busy":
+ case "pending":
+ case "running":
+ case "streaming":
+ case "working":
+ return "working";
+ default:
+ return undefined;
+ }
+}
+
+function request(method, params) {
+ const paneId = process.env.HERDR_PANE_ID;
+ const socketPath = process.env.HERDR_SOCKET_PATH;
+
+ if (!paneId || !socketPath) {
+ return Promise.resolve();
+ }
+
+ const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0")}`;
+ const request = {
+ id: requestId,
+ method,
+ params: {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: AGENT,
+ seq: nextReportSeq(),
+ ...params,
+ },
+ };
+
+ return new Promise((resolve) => {
+ const client = net.createConnection(socketPath, () => {
+ client.write(`${JSON.stringify(request)}\n`);
+ });
+
+ const finish = () => {
+ client.destroy();
+ resolve();
+ };
+
+ client.setTimeout(500, finish);
+ client.on("data", finish);
+ client.on("error", finish);
+ client.on("end", finish);
+ client.on("close", resolve);
+ });
+ }
+
+function reportSession(sessionID) {
+ if (!sessionID) {
+ return Promise.resolve();
+ }
+ return request("pane.report_agent_session", { agent_session_id: sessionID });
+}
+
+function reportState(state, sessionID) {
+ const params = { state };
+ if (sessionID) {
+ params.agent_session_id = sessionID;
+ }
+ return request("pane.report_agent", params);
+}
+
+function releaseAgent() {
+ return request("pane.release_agent", {});
+}
+
+ export const HerdrAgentStatePlugin = async () => {
+ if (
+ process.env.HERDR_ENV !== "1" ||
+ !process.env.HERDR_SOCKET_PATH ||
+ !process.env.HERDR_PANE_ID
+ ) {
+ return {};
+ }
+
+ return {
+ "chat.message": async ({ sessionID }) => {
+ await reportState("working", sessionID);
+ },
+ event: async ({ event }) => {
+ const type = event?.type;
+ const properties = event?.properties ?? {};
+ const sessionID = sessionIDFromProperties(properties);
+
+ switch (type) {
+ case "session.created":
+ case "session.updated":
+ await reportSession(sessionID);
+ break;
+ case "session.status": {
+ const state = stateFromSessionStatus(properties.status);
+ if (state) {
+ await reportState(state, sessionID);
+ } else {
+ await reportSession(sessionID);
+ }
+ break;
+ }
+ case "tool.execute.before":
+ case "tool.execute.after":
+ case "permission.replied":
+ case "question.replied":
+ case "question.rejected":
+ case "session.compacted":
+ await reportState("working", sessionID);
+ break;
+ case "permission.asked":
+ case "question.asked":
+ case "session.error":
+ await reportState("blocked", sessionID);
+ break;
+ case "session.idle":
+ await reportState("idle", sessionID);
+ break;
+ case "session.deleted":
+ await releaseAgent();
+ break;
+ default:
+ break;
+ }
+ },
+ };
+ };
diff --git a/.config/opencode/tui.json b/.config/opencode/tui.json
new file mode 100644
index 0000000..fb742f3
--- /dev/null
+++ b/.config/opencode/tui.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://opencode.ai/tui.json",
+ "theme": "gruvbox"
+}
diff --git a/.config/pi/agent/auth.json b/.config/pi/agent/auth.json
new file mode 100644
index 0000000..b2871e9
--- /dev/null
+++ b/.config/pi/agent/auth.json
@@ -0,0 +1,6 @@
+{
+ "opencode": {
+ "type": "api_key",
+ "key": "!rbw get opencode_api"
+ }
+}
diff --git a/.config/pi/agent/extensions/git-status.ts b/.config/pi/agent/extensions/git-status.ts
new file mode 100644
index 0000000..5312f83
--- /dev/null
+++ b/.config/pi/agent/extensions/git-status.ts
@@ -0,0 +1,191 @@
+/**
+ * Git/Jujutsu Status Extension - Shows current repo and branch in Pi's UI
+ *
+ * Supports both Git (.git) and Jujutsu (.jj) repositories.
+ *
+ * Displays in the status bar:
+ * 📂 repo-name î‚ main â— dirty (if uncommitted changes)
+ * 📂 repo-name î‚ îš® change-id (bookmark) â— dirty
+ *
+ * Refreshes on session start, model change, and after each turn.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+import { existsSync } from "node:fs";
+import { execSync } from "node:child_process";
+
+interface VcsInfo {
+ repo: string;
+ branch: string;
+ dirty: boolean;
+ ahead: number;
+ behind: number;
+ vcs: "git" | "jj";
+}
+
+function runCmd(cmd: string, cwd: string, timeout = 2000): string {
+ try {
+ return execSync(cmd, {
+ cwd,
+ encoding: "utf-8",
+ timeout,
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Detect which VCS is active by checking for .git or .jj directories
+ * walking up from cwd.
+ */
+function detectVcs(cwd: string): "git" | "jj" | null {
+ let dir = cwd;
+ // Walk up to 10 levels
+ for (let i = 0; i < 10; i++) {
+ if (existsSync(`${dir}/.jj`)) return "jj";
+ if (existsSync(`${dir}/.git`)) return "git";
+ const parent = dir.lastIndexOf("/");
+ if (parent <= 0) break;
+ dir = dir.slice(0, parent);
+ }
+ return null;
+}
+
+/** Get VCS info for Git repos */
+function getGitInfo(cwd: string): VcsInfo | null {
+ const topLevel = runCmd("git rev-parse --show-toplevel 2>/dev/null", cwd);
+ if (!topLevel) return null;
+
+ const branch = runCmd("git rev-parse --abbrev-ref HEAD 2>/dev/null", cwd);
+ if (!branch) return null;
+
+ const repo = topLevel.split("/").pop() || topLevel;
+ const status = runCmd("git status --porcelain 2>/dev/null", cwd);
+ const dirty = status.length > 0;
+
+ // Ahead/behind
+ const aheadBehind = runCmd(
+ "git rev-list --count --left-right HEAD...@{upstream} 2>/dev/null",
+ cwd,
+ );
+ let ahead = 0;
+ let behind = 0;
+ if (aheadBehind) {
+ const parts = aheadBehind.split("\t");
+ if (parts.length === 2) {
+ behind = parseInt(parts[0]) || 0;
+ ahead = parseInt(parts[1]) || 0;
+ }
+ }
+
+ return { repo, branch, dirty, ahead, behind, vcs: "git" };
+}
+
+/** Get VCS info for Jujutsu repos */
+function getJjInfo(cwd: string): VcsInfo | null {
+ const root = runCmd("jj workspace root 2>/dev/null", cwd);
+ if (!root) return null;
+
+ const repo = root.split("/").pop() || root;
+
+ // Get current change ID (short form)
+ const changeId = runCmd(
+ `jj log -r '@' --no-graph -T 'change_id.shortest(8)' 2>/dev/null`,
+ cwd,
+ );
+
+ // Get bookmarks on current change
+ const bookmarks = runCmd(
+ `jj log -r '@' --no-graph -T 'bookmarks' 2>/dev/null`,
+ cwd,
+ );
+
+ // Build a display-friendly branch name
+ let branch = changeId ? `îš® ${changeId}` : "îš® ???";
+ if (bookmarks) {
+ // bookmarks might be a space-separated list
+ const bm = bookmarks.trim();
+ if (bm) branch = bm;
+ }
+
+ // Check for dirty/uncommitted files
+ // jj status exits 0 and shows nothing if clean
+ const status = runCmd("jj status 2>/dev/null", cwd);
+ const dirty = status.length > 0 && !status.startsWith("The working copy is clean");
+
+ // jj doesn't have a direct ahead/behind concept in the same way
+ // We skip ahead/behind for jj
+
+ return { repo, branch, dirty, ahead: 0, behind: 0, vcs: "jj" };
+}
+
+function formatStatus(info: VcsInfo, theme: { fg: (style: string, text: string) => string }): string {
+ const repoStr = theme.fg("accent", `📂 ${info.repo}`);
+
+ // Different branch icon per VCS
+ const branchIcon = info.vcs === "jj" ? " " : " î‚ ";
+ const branchStr = theme.fg("accent", `${branchIcon} ${info.branch}`);
+
+ let statusStr = repoStr + branchStr;
+
+ if (info.dirty) {
+ statusStr += ` ${theme.fg("warning", "â—")}`;
+ }
+
+ // Only show ahead/behind for git
+ if (info.vcs === "git") {
+ if (info.behind > 0 && info.ahead > 0) {
+ statusStr += ` ${theme.fg("error", `↓${info.behind}`)} ${theme.fg("success", `↑${info.ahead}`)}`;
+ } else if (info.behind > 0) {
+ statusStr += ` ${theme.fg("error", `↓${info.behind}`)}`;
+ } else if (info.ahead > 0) {
+ statusStr += ` ${theme.fg("success", `↑${info.ahead}`)}`;
+ }
+ }
+
+ return statusStr;
+}
+
+function refreshStatus(ctx: ExtensionContext): void {
+ const cwd = ctx.cwd;
+ const theme = ctx.ui.theme;
+
+ const vcs = detectVcs(cwd);
+
+ if (vcs === "jj") {
+ const info = getJjInfo(cwd);
+ if (info) {
+ ctx.ui.setStatus("git-status", formatStatus(info, theme));
+ } else {
+ ctx.ui.setStatus("git-status", theme.fg("dim", "📂 jj repo (no changes)"));
+ }
+ } else if (vcs === "git") {
+ const info = getGitInfo(cwd);
+ if (info) {
+ ctx.ui.setStatus("git-status", formatStatus(info, theme));
+ } else {
+ ctx.ui.setStatus("git-status", theme.fg("dim", "📂 no git repo"));
+ }
+ } else {
+ ctx.ui.setStatus("git-status", theme.fg("dim", "📂 no VCS repo"));
+ }
+}
+
+export default function (pi: ExtensionAPI) {
+ // Show on session start
+ pi.on("session_start", async (_event, ctx) => {
+ refreshStatus(ctx);
+ });
+
+ // Refresh after each turn (to catch branch switches, commits, etc.)
+ pi.on("turn_end", async (_event, ctx) => {
+ refreshStatus(ctx);
+ });
+
+ // Refresh on model change
+ pi.on("model_select", async (_event, ctx) => {
+ refreshStatus(ctx);
+ });
+}
diff --git a/.config/pi/agent/extensions/herdr-agent-state.ts b/.config/pi/agent/extensions/herdr-agent-state.ts
new file mode 100644
index 0000000..0e620f1
--- /dev/null
+++ b/.config/pi/agent/extensions/herdr-agent-state.ts
@@ -0,0 +1,320 @@
+// installed by herdr
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// add custom hooks/plugins beside this file instead of editing it.
+// HERDR_INTEGRATION_ID=pi
+// HERDR_INTEGRATION_VERSION=2
+// @ts-nocheck
+
+import { createConnection } from "node:net";
+
+const HERDR_ENV = process.env.HERDR_ENV;
+const socketPath = process.env.HERDR_SOCKET_PATH;
+const paneId = process.env.HERDR_PANE_ID;
+const source = "herdr:pi";
+
+function enabled() {
+ return HERDR_ENV === "1" && !!socketPath && !!paneId;
+}
+
+function sendRequest(request: unknown): Promise {
+ if (!enabled()) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ socket.destroy();
+ resolve();
+ };
+
+ const socket = createConnection(socketPath!);
+ socket.on("error", finish);
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
+ socket.on("data", finish);
+ socket.on("end", finish);
+ const timeout = setTimeout(finish, 500);
+ timeout.unref?.();
+ });
+}
+
+type AgentState = "working" | "blocked" | "idle";
+
+type QueuedState = {
+ state: AgentState;
+ message?: string;
+ seq: number;
+};
+
+const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250);
+const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500);
+const retryableErrorPattern =
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
+let reportSeq = Date.now() * 1000;
+let currentAgentSessionId: string | undefined;
+let currentAgentSessionPath: string | undefined;
+
+function nextReportSeq(): number {
+ reportSeq += 1;
+ return reportSeq;
+}
+
+function parseDurationEnv(name: string, fallback: number): number {
+ const raw = process.env[name];
+ if (!raw) {
+ return fallback;
+ }
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return fallback;
+ }
+ return parsed;
+}
+
+function updateSessionRef(ctx: any): void {
+ try {
+ const file = ctx?.sessionManager?.getSessionFile?.();
+ currentAgentSessionPath =
+ typeof file === "string" && file.startsWith("/") ? file : undefined;
+ } catch {
+ currentAgentSessionPath = undefined;
+ }
+
+ try {
+ const id = ctx?.sessionManager?.getSessionId?.();
+ currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
+ } catch {
+ currentAgentSessionId = undefined;
+ }
+}
+
+function withSessionRef(params: Record): Record {
+ if (currentAgentSessionPath) {
+ return { ...params, agent_session_path: currentAgentSessionPath };
+ }
+ if (currentAgentSessionId) {
+ return { ...params, agent_session_id: currentAgentSessionId };
+ }
+ return params;
+}
+
+function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise {
+ return sendRequest({
+ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
+ method: "pane.report_agent",
+ params: withSessionRef({
+ pane_id: paneId,
+ source,
+ agent: "pi",
+ state,
+ message,
+ seq,
+ }),
+ });
+}
+
+let sendInFlight = false;
+let queuedState: QueuedState | undefined;
+
+function queueState(state: AgentState, message?: string): void {
+ queuedState = { state, message, seq: nextReportSeq() };
+ if (!sendInFlight) {
+ void drainStateQueue();
+ }
+}
+
+async function drainStateQueue(): Promise {
+ if (sendInFlight) {
+ return;
+ }
+
+ sendInFlight = true;
+ try {
+ while (queuedState) {
+ const next = queuedState;
+ queuedState = undefined;
+ await sendState(next.state, next.message, next.seq);
+ }
+ } finally {
+ sendInFlight = false;
+ if (queuedState) {
+ void drainStateQueue();
+ }
+ }
+}
+
+function lastAssistantMessage(messages: unknown[]): any | undefined {
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ const message = messages[i] as any;
+ if (message?.role === "assistant") {
+ return message;
+ }
+ }
+ return undefined;
+}
+
+function retryableErrorMessage(event: any): string | undefined {
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
+ const assistant = lastAssistantMessage(messages);
+ if (assistant?.stopReason !== "error") {
+ return undefined;
+ }
+
+ const errorMessage = String(assistant.errorMessage ?? "");
+ if (!retryableErrorPattern.test(errorMessage)) {
+ return undefined;
+ }
+ return errorMessage || "retryable provider error";
+}
+
+function releaseAgent(): Promise {
+ return sendRequest({
+ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
+ method: "pane.release_agent",
+ params: {
+ pane_id: paneId,
+ source,
+ agent: "pi",
+ seq: nextReportSeq(),
+ },
+ });
+}
+
+export default function (pi) {
+ if (!enabled()) {
+ return;
+ }
+
+ let agentActive = false;
+ let retryHoldActive = false;
+ let failureBlocked = false;
+ let failureMessage: string | undefined;
+ let blockedCount = 0;
+ let blockedMessage: string | undefined;
+ let lastState: AgentState | undefined;
+ let lastMessage: string | undefined;
+ let idleTimer: ReturnType | undefined;
+ let retryTimer: ReturnType | undefined;
+
+ function clearTimer(timer: ReturnType | undefined) {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+
+ function clearPendingTimers() {
+ clearTimer(idleTimer);
+ clearTimer(retryTimer);
+ idleTimer = undefined;
+ retryTimer = undefined;
+ }
+
+ function clearFailureState() {
+ retryHoldActive = false;
+ failureBlocked = false;
+ failureMessage = undefined;
+ }
+
+ function desiredState() {
+ if (blockedCount > 0) {
+ return { state: "blocked" as const, message: blockedMessage };
+ }
+ if (failureBlocked) {
+ return { state: "blocked" as const, message: failureMessage };
+ }
+ if (agentActive || retryHoldActive) {
+ return { state: "working" as const, message: undefined };
+ }
+ return { state: "idle" as const, message: undefined };
+ }
+
+ function publishState(force = false) {
+ const next = desiredState();
+ if (!force && next.state === lastState && next.message === lastMessage) {
+ return;
+ }
+ lastState = next.state;
+ lastMessage = next.message;
+ queueState(next.state, next.message);
+ }
+
+ function scheduleIdle() {
+ clearPendingTimers();
+ clearFailureState();
+ idleTimer = setTimeout(() => {
+ idleTimer = undefined;
+ publishState();
+ }, idleDebounceMs);
+ idleTimer.unref?.();
+ }
+
+ pi.on("session_start", (_event, ctx) => {
+ updateSessionRef(ctx);
+ publishState(true);
+ });
+
+ function holdForRetry(message: string) {
+ clearPendingTimers();
+ retryHoldActive = true;
+ failureBlocked = false;
+ failureMessage = message;
+ publishState();
+
+ retryTimer = setTimeout(() => {
+ retryTimer = undefined;
+ retryHoldActive = false;
+ failureBlocked = true;
+ publishState();
+ }, retryGraceMs);
+ retryTimer.unref?.();
+ }
+
+ pi.events.on("herdr:blocked", (data) => {
+ if (!data?.active) {
+ blockedCount = Math.max(0, blockedCount - 1);
+ if (blockedCount === 0) {
+ blockedMessage = undefined;
+ }
+ publishState();
+ return;
+ }
+
+ clearPendingTimers();
+ blockedCount += 1;
+ blockedMessage = data.label;
+ publishState();
+ });
+
+ pi.on("agent_start", () => {
+ clearPendingTimers();
+ clearFailureState();
+ agentActive = true;
+ publishState();
+ });
+
+ pi.on("agent_end", (event) => {
+ if (!agentActive) {
+ // Pi can emit duplicate/late end events while auto-retry is already
+ // holding the pane in Working. Do not let an unqualified duplicate end
+ // cancel the retry hold and publish a false Idle.
+ return;
+ }
+
+ agentActive = false;
+
+ const retryableMessage = retryableErrorMessage(event);
+ if (retryableMessage) {
+ holdForRetry(retryableMessage);
+ return;
+ }
+
+ scheduleIdle();
+ });
+
+ pi.on("session_shutdown", async () => {
+ clearPendingTimers();
+ await releaseAgent();
+ });
+}
diff --git a/.config/pi/agent/extensions/permission-gate.ts b/.config/pi/agent/extensions/permission-gate.ts
new file mode 100644
index 0000000..bea40a0
--- /dev/null
+++ b/.config/pi/agent/extensions/permission-gate.ts
@@ -0,0 +1,177 @@
+/**
+ * Permission Gate Extension - Like OpenCode's permission system
+ *
+ * Prompts for confirmation before:
+ * - Editing files
+ * - Writing new files
+ * - Reading files (optional, toggle with /perm read)
+ * - Running dangerous bash commands (rm -rf, sudo, chmod, etc.)
+ *
+ * Commands:
+ * /perm - Show current permission settings
+ * /perm read - Toggle confirmation for read operations
+ * /perm strict - Toggle strict mode (block if no UI)
+ *
+ * The extension remembers settings across the session.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+
+interface PermSettings {
+ confirmRead: boolean; // Ask before read tool
+ strictMode: boolean; // Block when no UI (vs allow)
+}
+
+const DEFAULT_SETTINGS: PermSettings = {
+ confirmRead: false,
+ strictMode: true,
+};
+
+let settings: PermSettings = { ...DEFAULT_SETTINGS };
+
+const DANGEROUS_PATTERNS = [
+ /\brm\s+(-rf?|--recursive)\b/i,
+ /\bsudo\b/i,
+ /\b(chmod|chown)\b.*777/i,
+ /\bdd\s+if=/i,
+ /\b>:?\s*\//, // redirect to root
+ /\|?\s*shutdown\b/i,
+ /\|?\s*reboot\b/i,
+ /\bmv\s+\/[\w\/]+\s+\/dev\/null/i,
+ /\b>\s*\/dev\/(sda|sdb|nvme|mmc)/i,
+];
+
+function isDangerousCommand(command: string): boolean {
+ return DANGEROUS_PATTERNS.some((p) => p.test(command));
+}
+
+async function confirmAction(
+ ctx: ExtensionContext,
+ toolName: string,
+ action: string,
+ details: string,
+): Promise {
+ if (!ctx.hasUI) {
+ if (settings.strictMode) {
+ return false; // Block in non-interactive mode
+ }
+ return true; // Allow in non-interactive mode if not strict
+ }
+
+ const choice = await ctx.ui.select(
+ `🔒 ${toolName.toUpperCase()} — ${action}\n\n${details}\n\nAllow?`,
+ ["Allow", "Deny"],
+ );
+
+ return choice === "Allow";
+}
+
+export default function (pi: ExtensionAPI) {
+ // ── Tool: edit ──
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "edit") {
+ const path = (event.input as { path?: string }).path ?? "unknown";
+ const oldText = (event.input as { oldText?: string }).oldText ?? "";
+ const newText = (event.input as { newText?: string }).newText ?? "";
+ const oldPreview = oldText.split("\n").slice(0, 3).join("\n");
+ const newPreview = newText.split("\n").slice(0, 3).join("\n");
+ const summary = `${oldText.length}→${newText.length} chars`;
+
+ const details = `📄 ${path}\n ${summary}\n\n-${oldPreview}\n+${newPreview}`;
+ const allowed = await confirmAction(ctx, "edit", "Edit file", details);
+ if (!allowed) {
+ return { block: true, reason: "Edit denied by user" };
+ }
+ }
+ });
+
+ // ── Tool: write ──
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "write") {
+ const path = (event.input as { path?: string }).path ?? "unknown";
+ const content = (event.input as { content?: string }).content ?? "";
+ const lineCount = content.split("\n").length;
+ const preview = content.split("\n").slice(0, 5).join("\n");
+
+ const details = `📄 ${path}\n ${lineCount} lines\n\n${preview}`;
+ const allowed = await confirmAction(ctx, "write", "Create file", details);
+ if (!allowed) {
+ return { block: true, reason: "Write denied by user" };
+ }
+ }
+ });
+
+ // ── Tool: read (optional, off by default) ──
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "read" && settings.confirmRead) {
+ const path = (event.input as { path?: string }).path ?? "unknown";
+
+ const details = `📄 ${path}`;
+ const allowed = await confirmAction(ctx, "read", "Read file", details);
+ if (!allowed) {
+ return { block: true, reason: "Read denied by user" };
+ }
+ }
+ });
+
+ // ── Tool: bash (dangerous commands only) ──
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "bash") {
+ const command = (event.input as { command?: string }).command ?? "";
+
+ if (isDangerousCommand(command)) {
+ const details = `💻 ${command.slice(0, 200)}`;
+ const allowed = await confirmAction(ctx, "bash", "Dangerous command", details);
+ if (!allowed) {
+ return { block: true, reason: "Dangerous command denied by user" };
+ }
+ }
+ }
+ });
+
+ // ── Command: /perm to view/toggle settings ──
+ pi.registerCommand("perm", {
+ description: "Show or toggle permission settings. Usage: /perm [read|strict]",
+ handler: async (args, ctx) => {
+ const arg = args?.trim().toLowerCase();
+
+ if (arg === "read") {
+ settings.confirmRead = !settings.confirmRead;
+ ctx.ui.notify(
+ `Read confirmation: ${settings.confirmRead ? "ON" : "OFF"}`,
+ settings.confirmRead ? "warning" : "info",
+ );
+ return;
+ }
+
+ if (arg === "strict") {
+ settings.strictMode = !settings.strictMode;
+ ctx.ui.notify(
+ `Strict mode: ${settings.strictMode ? "ON" : "OFF"} (non-interactive: ${settings.strictMode ? "block" : "allow"})`,
+ "info",
+ );
+ return;
+ }
+
+ // Show current settings
+ const theme = ctx.ui.theme;
+ ctx.ui.notify(
+ `🔒 Permissions\n` +
+ ` Edit: ${theme.fg("warning", "ALWAYS ASK")}\n` +
+ ` Write: ${theme.fg("warning", "ALWAYS ASK")}\n` +
+ ` Read: ${settings.confirmRead ? theme.fg("warning", "ASK") : theme.fg("success", "ALLOW")}\n` +
+ ` Bash: ${theme.fg("warning", "DANGEROUS ONLY")}\n` +
+ ` Strict: ${settings.strictMode ? theme.fg("warning", "ON") : theme.fg("success", "OFF")}\n` +
+ `\nCommands:\n` +
+ ` /perm read toggle read confirmation\n` +
+ ` /perm strict toggle strict mode`,
+ "info",
+ );
+ },
+ });
+
+ // ── Reset settings on session start ──
+ pi.on("session_start", async () => {
+ settings = { ...DEFAULT_SETTINGS };
+ });
+}
diff --git a/.config/pi/agent/extensions/pi-hunk.ts b/.config/pi/agent/extensions/pi-hunk.ts
new file mode 100644
index 0000000..88c0f7d
--- /dev/null
+++ b/.config/pi/agent/extensions/pi-hunk.ts
@@ -0,0 +1,209 @@
+/**
+ * Hunk diff integration for Pi
+ *
+ * Shows beautiful colored diffs inline in Pi's UI when files change,
+ * and provides /hunk command to open full review in the external hunk app.
+ *
+ * Features:
+ * - Colored hunk diff inline in edit tool results (Ctrl+E to expand)
+ * - Diff stats: +N / -M in collapsed view
+ * - /hunk [last|staged|diff] — open in external hunk TUI
+ */
+
+import type { ExtensionAPI, EditToolDetails } from "@earendil-works/pi-coding-agent";
+import { createEditTool, createWriteTool } from "@earendil-works/pi-coding-agent";
+import { Text } from "@earendil-works/pi-tui";
+import { writeFile, unlink } from "node:fs/promises";
+
+export default function (pi: ExtensionAPI) {
+ const cwd = process.cwd();
+ let lastPatch: string | null = null;
+
+ // ── Capture last patch for /hunk command ──
+ pi.on("tool_result", async (event, _ctx) => {
+ const msg = event as unknown as { toolName?: string; details?: { patch?: string } };
+ if (msg.toolName === "edit" && msg.details?.patch) {
+ lastPatch = msg.details.patch;
+ }
+ });
+
+ // ── /hunk command (external app) ──
+ pi.registerCommand("hunk", {
+ description: "View diffs in external hunk TUI (usage: /hunk [last|staged|diff])",
+ handler: async (args, ctx) => {
+ const arg = args.trim().toLowerCase();
+
+ try {
+ if (arg === "staged") {
+ await pi.exec("hunk", ["diff", "--staged"], { env: process.env });
+ } else if (arg === "diff" || arg === "working") {
+ await pi.exec("hunk", ["diff"], { env: process.env });
+ } else if (arg === "" || arg === "last") {
+ if (!lastPatch) {
+ ctx.ui.notify("No recent edits. Try /hunk diff or /hunk staged", "warning");
+ return;
+ }
+
+ const tmpFile = `/tmp/pi-hunk-patch-${Date.now()}.patch`;
+ await writeFile(tmpFile, lastPatch, "utf-8");
+ await pi.exec("hunk", ["patch", tmpFile], { env: process.env });
+ try { await unlink(tmpFile); } catch {}
+ } else {
+ ctx.ui.notify("Usage: /hunk [last|staged|diff]", "warning");
+ }
+ } catch (err: any) {
+ ctx.ui.notify(`hunk error: ${err.message}`, "error");
+ }
+ },
+ });
+
+ // ── Inline colored hunk rendering for edit tool ──
+ const originalEdit = createEditTool(cwd);
+ pi.registerTool({
+ name: "edit",
+ label: "edit",
+ description: originalEdit.description,
+ parameters: originalEdit.parameters,
+
+ async execute(toolCallId, params, signal, onUpdate) {
+ return originalEdit.execute(toolCallId, params, signal, onUpdate);
+ },
+
+ renderCall(args, theme, _context) {
+ const path = theme.fg("accent", args.path);
+ const oldLen = args.oldText?.length ?? 0;
+ const newLen = args.newText?.length ?? 0;
+ return new Text(
+ `${theme.fg("toolTitle", theme.bold("hunk "))}${path}${theme.fg("dim", ` (~${oldLen}→~${newLen} chars)`)}`,
+ 0,
+ 0,
+ );
+ },
+
+ renderResult(result, { expanded, isPartial }, theme, _context) {
+ if (isPartial) {
+ return new Text(theme.fg("warning", "Applying hunk..."), 0, 0);
+ }
+
+ const details = result.details as EditToolDetails | undefined;
+ const content = result.content[0];
+
+ if (content?.type === "text" && content.text.startsWith("Error")) {
+ return new Text(theme.fg("error", `✗ ${content.text}`), 0, 0);
+ }
+
+ if (!details?.diff) {
+ return new Text(theme.fg("success", "✓ Applied"), 0, 0);
+ }
+
+ const diffLines = details.diff.split("\n");
+
+ // Count stats
+ let additions = 0;
+ let removals = 0;
+ for (const line of diffLines) {
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
+ if (line.startsWith("-") && !line.startsWith("---")) removals++;
+ }
+
+ // Always show full colored diff
+ let text = theme.fg("toolTitle", theme.bold(" hunk ")) + theme.fg("success", `+${additions}`) + theme.fg("dim", " / ") + theme.fg("error", `-${removals}`);
+ const maxLines = 50;
+ let shown = 0;
+
+ for (const rawLine of diffLines) {
+ if (shown >= maxLines) {
+ text += `\n${theme.fg("muted", ` ... ${diffLines.length - shown} more lines`)}`;
+ break;
+ }
+
+ const line = rawLine.replace(/\r$/, "");
+
+ if (line.startsWith("--- ") || line.startsWith("+++ ")) {
+ if (line.startsWith("+++ ")) {
+ const filePath = line.slice(6).replace(/^[ab]\//, "");
+ text += `\n${theme.fg("borderMuted", "─".repeat(4))} ${theme.fg("accent", filePath)}`;
+ }
+ continue;
+ }
+
+ if (line.startsWith("@@ ")) {
+ const match = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@\s*(.*)/);
+ const lineNum = match?.[1] ?? "";
+ const context = match?.[2]?.trim() ?? "";
+ const header = context
+ ? ` @@ -${lineNum} @@ ${context}`
+ : ` @@ -${lineNum} @@`;
+ text += `\n${theme.fg("accent", header)}`;
+ shown++;
+ continue;
+ }
+
+ if (line.startsWith("+")) {
+ text += `\n${theme.fg("success", `+ ${line.slice(1)}`)}`;
+ shown++;
+ } else if (line.startsWith("-")) {
+ text += `\n${theme.fg("error", `- ${line.slice(1)}`)}`;
+ shown++;
+ } else if (line.startsWith(" ")) {
+ text += `\n${theme.fg("dim", ` ${line.slice(1)}`)}`;
+ shown++;
+ } else if (line.startsWith("\\")) {
+ text += `\n${theme.fg("warning", ` ${line.slice(0, 60)}`)}`;
+ shown++;
+ } else if (line) {
+ text += `\n${theme.fg("dim", ` ${line}`)}`;
+ shown++;
+ }
+ }
+
+ return new Text(text, 0, 0);
+ },
+ });
+
+ // ── Also enhance write tool ──
+ const originalWrite = createWriteTool(cwd);
+ pi.registerTool({
+ name: "write",
+ label: "write",
+ description: originalWrite.description,
+ parameters: originalWrite.parameters,
+
+ async execute(toolCallId, params, signal, onUpdate) {
+ return originalWrite.execute(toolCallId, params, signal, onUpdate);
+ },
+
+ renderCall(args, theme, _context) {
+ const path = theme.fg("accent", args.path);
+ const lineCount = args.content.split("\n").length;
+ return new Text(
+ `${theme.fg("toolTitle", theme.bold("write "))}${path}${theme.fg("dim", ` (${lineCount} lines)`)}`,
+ 0,
+ 0,
+ );
+ },
+
+ renderResult(result, { isPartial, expanded }, theme, _context) {
+ if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0);
+
+ const content = result.content[0];
+ if (content?.type === "text" && content.text.startsWith("Error")) {
+ return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
+ }
+
+ let text = theme.fg("success", "✨ Created");
+
+ if (expanded && content?.type === "text") {
+ const lines = content.text.split("\n").slice(0, 20);
+ for (const line of lines) {
+ text += `\n${theme.fg("success", `+ ${line}`)}`;
+ }
+ if (content.text.split("\n").length > 20) {
+ text += `\n${theme.fg("muted", `... ${content.text.split("\n").length - 20} more lines`)}`;
+ }
+ }
+
+ return new Text(text, 0, 0);
+ },
+ });
+}
diff --git a/.config/pi/agent/extensions/session-switcher.ts b/.config/pi/agent/extensions/session-switcher.ts
new file mode 100644
index 0000000..fc4f28c
--- /dev/null
+++ b/.config/pi/agent/extensions/session-switcher.ts
@@ -0,0 +1,217 @@
+/**
+ * Session Switcher & Theme Switcher Extension
+ *
+ * Features:
+ * - Ctrl+X then S — open session picker and switch
+ * - Ctrl+X then T — open theme picker and switch
+ * - /sessions — list sessions and switch
+ * - /theme — list themes and switch
+ */
+
+import type { ExtensionAPI, SessionInfo } from "@earendil-works/pi-coding-agent";
+import { SessionManager, CustomEditor } from "@earendil-works/pi-coding-agent";
+import { matchesKey, Key, visibleWidth } from "@earendil-works/pi-tui";
+
+// ── Shared state ─────────────────────────────────────────────────────
+
+let pendingSessionPath: string | undefined;
+
+// ── Formatting helpers ───────────────────────────────────────────────
+
+function formatSessionLabel(session: SessionInfo): string {
+ const name = session.name ?? session.firstMessage.slice(0, 80);
+ const cwd = session.cwd ? session.cwd.replace(/^\/home\/[^/]+/, "~") : "?";
+ const date = session.modified.toLocaleDateString();
+ const count = session.messageCount;
+ return `${name} ${cwd} ${date} (${count} msgs)`;
+}
+
+// ── Session picker ──────────────────────────────────────────────────
+
+async function pickSession(ctx: any): Promise {
+ const allSessions = await SessionManager.listAll();
+ if (allSessions.length === 0) {
+ ctx.ui.notify("No sessions found", "info");
+ return;
+ }
+ allSessions.sort((a: SessionInfo, b: SessionInfo) => b.modified.getTime() - a.modified.getTime());
+ const pathByLabel = new Map();
+ const labels = allSessions.map((s: SessionInfo) => {
+ const label = formatSessionLabel(s);
+ pathByLabel.set(label, s.path);
+ return label;
+ });
+ const selected = await ctx.ui.select("Select a session to switch to:", labels);
+ if (!selected) return;
+ return pathByLabel.get(selected);
+}
+
+// ── Theme picker ────────────────────────────────────────────────────
+
+async function pickTheme(ctx: any): Promise {
+ try {
+ const themes = ctx.ui.getAllThemes();
+ if (themes.length === 0) {
+ ctx.ui.notify("No themes available", "info");
+ return;
+ }
+
+ const labels = themes.map((t: any) => t.name);
+ const selected = await ctx.ui.select("Select a theme:", labels);
+ if (!selected) return;
+
+ const result = ctx.ui.setTheme(selected);
+ if (!result.success) {
+ ctx.ui.notify(`Failed: ${result.error}`, "error");
+ } else {
+ ctx.ui.notify(`Theme switched to: ${selected}`, "info");
+ }
+ } catch (err) {
+ ctx.ui.notify(`Theme error: ${err}`, "error");
+ }
+}
+
+// ── Prefix command definitions ──────────────────────────────────────
+
+interface PrefixCommand {
+ key: string;
+ label: string;
+ action: (ctx: any) => Promise;
+}
+
+const PREFIX_COMMANDS: PrefixCommand[] = [
+ { key: "s", label: "sessions", action: async (ctx) => {
+ const path = await pickSession(ctx);
+ if (!path) return;
+ pendingSessionPath = path;
+ ctx.ui.setEditorText("/sessions");
+ ctx.ui.notify("Press Enter to switch sessions", "info");
+ }},
+ { key: "t", label: "theme", action: async (ctx) => {
+ await pickTheme(ctx);
+ }},
+];
+
+// ── Custom editor with Ctrl+X prefix mode ────────────────────────────
+
+function buildPrefixHint(): string {
+ return " CX- " + PREFIX_COMMANDS.map((c) => `${c.key}:${c.label}`).join(" ") + " ";
+}
+
+class SessionPrefixEditor extends CustomEditor {
+ private prefixActive = false;
+ private prefixTimer: ReturnType | null = null;
+ private savedBorderColor: ((str: string) => string) | null = null;
+
+ private onTrigger: (key: string) => void;
+ private appTheme: any;
+
+ constructor(tui: any, theme: any, keybindings: any, onTrigger: (key: string) => void, appTheme: any) {
+ super(tui, theme, keybindings);
+ this.onTrigger = onTrigger;
+ this.appTheme = appTheme;
+ this.savedBorderColor = null;
+ }
+
+ private enterPrefix(): void {
+ if (this.prefixActive) {
+ if (this.prefixTimer !== null) clearTimeout(this.prefixTimer);
+ this.prefixTimer = setTimeout(() => this.clearPrefix(), 1500);
+ return;
+ }
+ this.prefixActive = true;
+ if (!this.savedBorderColor) this.savedBorderColor = this.borderColor;
+ this.borderColor = (str: string) => this.appTheme?.fg ? this.appTheme.fg("warning", str) : str;
+ this.invalidate();
+ this.prefixTimer = setTimeout(() => this.clearPrefix(), 1500);
+ }
+
+ private clearPrefix(): void {
+ if (!this.prefixActive) return;
+ this.prefixActive = false;
+ if (this.prefixTimer !== null) { clearTimeout(this.prefixTimer); this.prefixTimer = null; }
+ if (this.savedBorderColor) { this.borderColor = this.savedBorderColor; this.savedBorderColor = null; }
+ this.invalidate();
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, Key.ctrl("x"))) {
+ this.enterPrefix();
+ return;
+ }
+ if (this.prefixActive) {
+ this.clearPrefix();
+ for (const cmd of PREFIX_COMMANDS) {
+ if (data === cmd.key) {
+ this.onTrigger(cmd.key);
+ return;
+ }
+ }
+ }
+ super.handleInput(data);
+ }
+
+ render(width: number): string[] {
+ const lines = super.render(width);
+ if (this.prefixActive && lines.length > 0) {
+ const hint = buildPrefixHint();
+ const last = lines.length - 1;
+ const lineLen = visibleWidth(lines[last]!);
+ if (lineLen >= hint.length) {
+ lines[last] = lines[last]!.slice(0, lineLen - hint.length) + hint;
+ }
+ }
+ return lines;
+ }
+}
+
+// ── Entry point ──────────────────────────────────────────────────────
+
+export default function (pi: ExtensionAPI) {
+ // ── /sessions command ─────────────────────────────────────────
+ pi.registerCommand("sessions", {
+ description: "List all sessions and switch to one",
+ handler: async (_args, ctx) => {
+ if (pendingSessionPath) {
+ const path = pendingSessionPath;
+ pendingSessionPath = undefined;
+ const r = await ctx.switchSession(path, {
+ withSession: async (rctx: any) => { rctx.ui.notify("Switched session", "info"); },
+ });
+ if (r.cancelled) ctx.ui.notify("Session switch cancelled", "info");
+ return;
+ }
+ const path = await pickSession(ctx);
+ if (!path) return;
+ const r = await ctx.switchSession(path, {
+ withSession: async (rctx: any) => { rctx.ui.notify("Switched session", "info"); },
+ });
+ if (r.cancelled) ctx.ui.notify("Session switch cancelled", "info");
+ },
+ });
+
+ // ── /theme command ────────────────────────────────────────────
+ pi.registerCommand("theme", {
+ description: "Switch theme (gruvbox-dark, dark, light, etc.)",
+ handler: async (_args, ctx) => {
+ await pickTheme(ctx);
+ },
+ });
+
+ // ── Wrap editor with prefix handling ──────────────────────────
+ pi.on("session_start", (_event, ctx) => {
+ const appTheme = (ctx.ui as any).theme;
+ if (!appTheme || ctx.mode !== "tui") return;
+
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
+ return new SessionPrefixEditor(tui, theme, keybindings, async (key) => {
+ for (const cmd of PREFIX_COMMANDS) {
+ if (key === cmd.key) {
+ await cmd.action(ctx);
+ return;
+ }
+ }
+ }, appTheme);
+ });
+ });
+}
diff --git a/.config/pi/agent/extensions/todo.ts b/.config/pi/agent/extensions/todo.ts
new file mode 100644
index 0000000..67cba74
--- /dev/null
+++ b/.config/pi/agent/extensions/todo.ts
@@ -0,0 +1,297 @@
+/**
+ * Todo Extension - Demonstrates state management via session entries
+ *
+ * This extension:
+ * - Registers a `todo` tool for the LLM to manage todos
+ * - Registers a `/todos` command for users to view the list
+ *
+ * State is stored in tool result details (not external files), which allows
+ * proper branching - when you branch, the todo state is automatically
+ * correct for that point in history.
+ */
+
+import { StringEnum } from "@earendil-works/pi-ai";
+import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
+import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
+import { Type } from "typebox";
+
+interface Todo {
+ id: number;
+ text: string;
+ done: boolean;
+}
+
+interface TodoDetails {
+ action: "list" | "add" | "toggle" | "clear";
+ todos: Todo[];
+ nextId: number;
+ error?: string;
+}
+
+const TodoParams = Type.Object({
+ action: StringEnum(["list", "add", "toggle", "clear"] as const),
+ text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
+ id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
+});
+
+/**
+ * UI component for the /todos command
+ */
+class TodoListComponent {
+ private todos: Todo[];
+ private theme: Theme;
+ private onClose: () => void;
+ private cachedWidth?: number;
+ private cachedLines?: string[];
+
+ constructor(todos: Todo[], theme: Theme, onClose: () => void) {
+ this.todos = todos;
+ this.theme = theme;
+ this.onClose = onClose;
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
+ this.onClose();
+ }
+ }
+
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) {
+ return this.cachedLines;
+ }
+
+ const lines: string[] = [];
+ const th = this.theme;
+
+ lines.push("");
+ const title = th.fg("accent", " Todos ");
+ const headerLine =
+ th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
+ lines.push(truncateToWidth(headerLine, width));
+ lines.push("");
+
+ if (this.todos.length === 0) {
+ lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
+ } else {
+ const done = this.todos.filter((t) => t.done).length;
+ const total = this.todos.length;
+ lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
+ lines.push("");
+
+ for (const todo of this.todos) {
+ const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
+ const id = th.fg("accent", `#${todo.id}`);
+ const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
+ lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
+ }
+ }
+
+ lines.push("");
+ lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
+ lines.push("");
+
+ this.cachedWidth = width;
+ this.cachedLines = lines;
+ return lines;
+ }
+
+ invalidate(): void {
+ this.cachedWidth = undefined;
+ this.cachedLines = undefined;
+ }
+}
+
+export default function (pi: ExtensionAPI) {
+ // In-memory state (reconstructed from session on load)
+ let todos: Todo[] = [];
+ let nextId = 1;
+
+ /**
+ * Reconstruct state from session entries.
+ * Scans tool results for this tool and applies them in order.
+ */
+ const reconstructState = (ctx: ExtensionContext) => {
+ todos = [];
+ nextId = 1;
+
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (entry.type !== "message") continue;
+ const msg = entry.message;
+ if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
+
+ const details = msg.details as TodoDetails | undefined;
+ if (details) {
+ todos = details.todos;
+ nextId = details.nextId;
+ }
+ }
+ };
+
+ // Reconstruct state on session events
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+ // Register the todo tool for the LLM
+ pi.registerTool({
+ name: "todo",
+ label: "Todo",
+ description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
+ parameters: TodoParams,
+
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
+ switch (params.action) {
+ case "list":
+ return {
+ content: [
+ {
+ type: "text",
+ text: todos.length
+ ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
+ : "No todos",
+ },
+ ],
+ details: { action: "list", todos: [...todos], nextId } as TodoDetails,
+ };
+
+ case "add": {
+ if (!params.text) {
+ return {
+ content: [{ type: "text", text: "Error: text required for add" }],
+ details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
+ };
+ }
+ const newTodo: Todo = { id: nextId++, text: params.text, done: false };
+ todos.push(newTodo);
+ return {
+ content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
+ details: { action: "add", todos: [...todos], nextId } as TodoDetails,
+ };
+ }
+
+ case "toggle": {
+ if (params.id === undefined) {
+ return {
+ content: [{ type: "text", text: "Error: id required for toggle" }],
+ details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
+ };
+ }
+ const todo = todos.find((t) => t.id === params.id);
+ if (!todo) {
+ return {
+ content: [{ type: "text", text: `Todo #${params.id} not found` }],
+ details: {
+ action: "toggle",
+ todos: [...todos],
+ nextId,
+ error: `#${params.id} not found`,
+ } as TodoDetails,
+ };
+ }
+ todo.done = !todo.done;
+ return {
+ content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
+ details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
+ };
+ }
+
+ case "clear": {
+ const count = todos.length;
+ todos = [];
+ nextId = 1;
+ return {
+ content: [{ type: "text", text: `Cleared ${count} todos` }],
+ details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
+ };
+ }
+
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+ details: {
+ action: "list",
+ todos: [...todos],
+ nextId,
+ error: `unknown action: ${params.action}`,
+ } as TodoDetails,
+ };
+ }
+ },
+
+ renderCall(args, theme, _context) {
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
+ if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
+ if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme, _context) {
+ const details = result.details as TodoDetails | undefined;
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
+ }
+
+ if (details.error) {
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
+ }
+
+ const todoList = details.todos;
+
+ switch (details.action) {
+ case "list": {
+ if (todoList.length === 0) {
+ return new Text(theme.fg("dim", "No todos"), 0, 0);
+ }
+ let listText = theme.fg("muted", `${todoList.length} todo(s):`);
+ const display = expanded ? todoList : todoList.slice(0, 5);
+ for (const t of display) {
+ const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
+ const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
+ listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
+ }
+ if (!expanded && todoList.length > 5) {
+ listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
+ }
+ return new Text(listText, 0, 0);
+ }
+
+ case "add": {
+ const added = todoList[todoList.length - 1];
+ return new Text(
+ theme.fg("success", "✓ Added ") +
+ theme.fg("accent", `#${added.id}`) +
+ " " +
+ theme.fg("muted", added.text),
+ 0,
+ 0,
+ );
+ }
+
+ case "toggle": {
+ const text = result.content[0];
+ const msg = text?.type === "text" ? text.text : "";
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
+ }
+
+ case "clear":
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
+ }
+ },
+ });
+
+ // Register the /todos command for users
+ pi.registerCommand("todos", {
+ description: "Show all todos on the current branch",
+ handler: async (_args, ctx) => {
+ if (ctx.mode !== "tui") {
+ ctx.ui.notify("/todos requires interactive mode", "error");
+ return;
+ }
+
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
+ return new TodoListComponent(todos, theme, () => done());
+ });
+ },
+ });
+}
diff --git a/.config/pi/agent/extensions/web-access.ts b/.config/pi/agent/extensions/web-access.ts
new file mode 100644
index 0000000..861074c
--- /dev/null
+++ b/.config/pi/agent/extensions/web-access.ts
@@ -0,0 +1,420 @@
+/**
+ * Web Access Extension - Fetch URLs and search the web
+ *
+ * Registers two custom tools:
+ * - web_fetch: Fetch and display content from a URL
+ * - web_search: Search the web (uses DuckDuckGo, no API key needed)
+ *
+ * Usage examples for the LLM:
+ * "Fetch https://example.com and summarize it"
+ * "Search for Go tutorials"
+ * "What's the latest news about Kubernetes?"
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { StringEnum } from "@earendil-works/pi-ai";
+import { Type } from "typebox";
+import { Text } from "@earendil-works/pi-tui";
+
+const TIMEOUT = 15_000; // 15 seconds
+
+/** Simple HTML tag stripper for cleaner text output */
+function stripHtml(html: string): string {
+ return html
+ .replace(/