From d06784958d73159b5e4abafe5114804662114ed7 Mon Sep 17 00:00:00 2001 From: Ryan Schanzenbacher Date: Thu, 9 Jul 2026 22:15:53 -0400 Subject: move ubersicht modules in tree --- .../lib/components/data/battery.jsx | 183 ++++++++++++ .../lib/components/data/browser-track.jsx | 147 ++++++++++ .../simple-bar-source/lib/components/data/cpu.jsx | 153 ++++++++++ .../lib/components/data/crypto.jsx | 160 +++++++++++ .../lib/components/data/data-widget-loader.jsx | 37 +++ .../lib/components/data/data-widget.jsx | 136 +++++++++ .../lib/components/data/date-display.jsx | 118 ++++++++ .../lib/components/data/github.jsx | 105 +++++++ .../simple-bar-source/lib/components/data/gpu.jsx | 128 +++++++++ .../lib/components/data/graph.jsx | 87 ++++++ .../lib/components/data/keyboard.jsx | 108 +++++++ .../lib/components/data/memory.jsx | 134 +++++++++ .../simple-bar-source/lib/components/data/mic.jsx | 152 ++++++++++ .../simple-bar-source/lib/components/data/mpd.jsx | 228 +++++++++++++++ .../lib/components/data/music.jsx | 161 +++++++++++ .../lib/components/data/netstats.jsx | 182 ++++++++++++ .../lib/components/data/next-meeting.jsx | 310 +++++++++++++++++++++ .../lib/components/data/notifications.jsx | 189 +++++++++++++ .../lib/components/data/sound.jsx | 154 ++++++++++ .../lib/components/data/specter.jsx | 24 ++ .../lib/components/data/spotify.jsx | 189 +++++++++++++ .../lib/components/data/stock.jsx | 211 ++++++++++++++ .../simple-bar-source/lib/components/data/time.jsx | 144 ++++++++++ .../lib/components/data/user-widgets.jsx | 181 ++++++++++++ .../lib/components/data/viscosity-vpn.jsx | 144 ++++++++++ .../lib/components/data/weather.jsx | 214 ++++++++++++++ .../simple-bar-source/lib/components/data/wifi.jsx | 154 ++++++++++ .../lib/components/data/youtube-music.jsx | 191 +++++++++++++ .../simple-bar-source/lib/components/data/zoom.jsx | 107 +++++++ 29 files changed, 4431 insertions(+) create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/battery.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/browser-track.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/cpu.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/crypto.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget-loader.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/date-display.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/github.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/gpu.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/graph.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/keyboard.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/memory.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mic.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mpd.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/music.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/netstats.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/notifications.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/sound.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/specter.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/spotify.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/stock.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/time.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/user-widgets.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/viscosity-vpn.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/weather.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/wifi.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/youtube-music.jsx create mode 100755 users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/zoom.jsx (limited to 'users/ryan/modules/simple-bar/simple-bar-source/lib/components/data') diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/battery.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/battery.jsx new file mode 100755 index 0000000..1f08af6 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/battery.jsx @@ -0,0 +1,183 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { batteryStyles as styles } from "../../styles/components/data/battery"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * Battery widget component + * @returns {JSX.Element|null} The battery widget component + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, batteryWidgetOptions } = settings; + const { batteryWidget } = widgets; + const { + refreshFrequency, + toggleCaffeinateOnClick, + caffeinateOption, + disableCaffeinateInvertedBackground, + showOnDisplay, + showIcon, + } = batteryWidgetOptions; + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && batteryWidget; + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + // Reset the widget state + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch battery information and update the state + */ + const getBattery = React.useCallback(async () => { + if (!visible) return; + // Fetch battery information and parse the results + const [system, percentage, status, caffeinate, lowPowerMode] = + await Promise.all([ + Utils.getSystem(), + Utils.cachedRun( + `pmset -g batt | grep -Eo '[0-9]+%' | head -1 | tr -d '%'`, + refresh, + ), + Utils.cachedRun( + `pmset -g batt | head -1 | grep -q 'AC Power' && echo 'AC' || echo 'Batt'`, + refresh, + ), + Uebersicht.run(`pgrep caffeinate -d`), + Utils.cachedRun( + `pmset -g | awk '/lowpowermode|powermode/ {print $2; exit}'`, + refresh, + ), + ]); + setState({ + system, + percentage: parseInt(percentage, 10), + charging: Utils.cleanupOutput(status) === "AC", + caffeinate: Utils.cleanupOutput(caffeinate), + lowPowerMode: Utils.cleanupOutput(lowPowerMode) === "1", + }); + setLoading(false); + }, [visible, refresh]); + + // Use server socket to fetch battery data + useServerSocket("battery", visible, getBattery, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getBattery, refresh); + + if (loading) return ; + if (!state) return null; + + const { system, percentage, charging, caffeinate, lowPowerMode } = state; + const isLowBattery = !charging && percentage < 20; + + const classes = Utils.classNames("battery", { + "battery--low": isLowBattery, + "battery--low-power-mode": lowPowerMode, + "battery--caffeinate": + !disableCaffeinateInvertedBackground && caffeinate.length > 0, + }); + + const transformValue = getTransform(percentage); + + /** + * Handle click event to toggle caffeinate mode + * @param {React.MouseEvent} e - The click event + */ + const onClick = async (e) => { + Utils.clickEffect(e); + await toggleCaffeinate(system, caffeinate, caffeinateOption, pushMissive); + getBattery(); + }; + + const onClickProp = toggleCaffeinateOnClick ? { onClick } : {}; + + const Icon = () => ( +
+
+
+ {charging && ( + + + + )} +
+
+ ); + + return ( + + {caffeinate.length > 0 && ( + + + + )} + {percentage}% + + ); +}); + +Widget.displayName = "Battery"; + +/** + * Get the transform value for the battery icon based on the percentage + * @param {number} value - The battery percentage + * @returns {string} The transform value + */ +function getTransform(value) { + let transform = `0.${value}`; + if (value === 100) transform = "1"; + if (value < 10) transform = `0.0${value}`; + return `scaleX(${transform})`; +} + +/** + * Toggle caffeinate mode on or off + * @param {string} system - The system architecture + * @param {string} caffeinate - The current caffeinate state + * @param {string} option - The caffeinate option + * @param {function} pushMissive - Function to push notifications + */ +async function toggleCaffeinate(system, caffeinate, option, pushMissive) { + const command = + system === "x86_64" ? "caffeinate" : "arch -arch arm64 caffeinate"; + if (caffeinate.length === 0) { + Uebersicht.run(`${command} ${option} &`); + Utils.notification("Enabling caffeinate...", pushMissive); + } else { + await Uebersicht.run("pkill -f caffeinate"); + Utils.notification("Disabling caffeinate...", pushMissive); + } +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/browser-track.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/browser-track.jsx new file mode 100755 index 0000000..d320e4a --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/browser-track.jsx @@ -0,0 +1,147 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { browserTrackStyles as styles } from "../../styles/components/data/browser-track"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * BrowserTrack Widget component + * @returns {JSX.Element|null} The BrowserTrack widget + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, browserTrackWidgetOptions } = settings; + const { browserTrackWidget } = widgets; + const { refreshFrequency, showSpecter, showOnDisplay, showIcon } = + browserTrackWidgetOptions; + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && browserTrackWidget; + + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const ref = React.useRef(); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches the current browser track information + */ + const getBrowserTrack = React.useCallback(async () => { + if (!visible) return; + const [firefoxStatus, firefoxDevStatus] = await Promise.all([ + Utils.cachedRun( + `pgrep -xq 'Firefox' && echo "true" || echo "false"`, + refresh, + ), + Utils.cachedRun( + `pgrep -xq 'Firefox Developer Edition' && echo "true" || echo "false"`, + refresh, + ), + ]); + const isFirefoxDevRunning = + Utils.cleanupOutput(firefoxDevStatus) === "true"; + const isFirefoxRunning = Utils.cleanupOutput(firefoxStatus) === "true"; + const scriptNamePrefix = isFirefoxDevRunning + ? "firefox-dev" + : isFirefoxRunning + ? "firefox" + : "browser"; + + const [browserTrackOutput, spotifyStatus] = await Promise.all([ + Utils.cachedRun( + `osascript ./simple-bar/lib/scripts/${scriptNamePrefix}-audio.applescript 2>&1`, + refresh, + ), + Utils.cachedRun( + `pgrep -fq '[S]potify Helper' && echo "true" || echo "false"`, + refresh, + ), + ]); + const browserTrack = JSON.parse(browserTrackOutput); + setState({ + ...browserTrack, + isSpotifyRunning: Utils.cleanupOutput(spotifyStatus) === "true", + }); + setLoading(false); + }, [visible, refresh]); + + useServerSocket( + "browser-track", + visible, + getBrowserTrack, + resetWidget, + setLoading, + ); + useWidgetRefresh(visible, getBrowserTrack, refresh); + + if (loading) return ; + if (!state) return null; + const { browser, title, isSpotifyRunning } = state; + + if (!browser?.length || !title?.length || isSpotifyRunning) return null; + + /** + * Icon component for displaying browser and playing icons + * @returns {JSX.Element} The icon component + */ + const Icon = () => { + const BrowserIcon = getIcon(browser); + return ( +
+ + + + +
+ ); + }; + + return ( + + {title} + + ); +}); + +Widget.displayName = "BrowserTrack"; + +/** + * Returns the appropriate icon component based on the browser name + * @param {string} browser - The name of the browser + * @returns {JSX.Element} The icon component for the browser + */ +function getIcon(browser) { + if (browser === "chrome") return Icons.GoogleChrome; + if (browser === "brave") return Icons.BraveBrowser; + if (browser === "safari") return Icons.Safari; + if (browser === "firefox") return Icons.Firefox; + return Icons.Default; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/cpu.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/cpu.jsx new file mode 100755 index 0000000..378b3c9 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/cpu.jsx @@ -0,0 +1,153 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import Graph from "./graph.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { cpuStyles as styles } from "../../styles/components/data/cpu"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 2000; +const GRAPH_LENGTH = 50; + +/** + * CPU Widget component + * @returns {JSX.Element|null} The CPU widget + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, cpuWidgetOptions } = settings; + const { cpuWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + displayAsGraph, + cpuMonitorApp, + showIcon, + cpuUsageThreshold, + } = cpuWidgetOptions; + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && cpuWidget; + + // Set the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [graph, setGraph] = React.useState([]); + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setGraph([]); + }; + + /** + * Fetch CPU usage data + */ + const getCpu = React.useCallback(async () => { + if (!visible) return; + try { + const usage = await Utils.cachedRun( + `top -l 2 | awk '/CPU usage/ && NR > 10 {gsub(/%/, "", $7); print int(100 - $7); exit}'`, + refresh, + ); + const formattedUsage = { usage: parseInt(usage, 10) }; + setState(formattedUsage); + if (displayAsGraph) { + Utils.addToGraphHistory(formattedUsage, setGraph, GRAPH_LENGTH); + } + setLoading(false); + } catch { + setTimeout(getCpu, 1000); + } + }, [displayAsGraph, setGraph, visible, refresh]); + + // Use server socket to fetch CPU data + useServerSocket("cpu", visible, getCpu, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getCpu, refresh); + + if (loading) return ; + if (!state) return null; + + const { usage } = state; + const threshold = Number(cpuUsageThreshold) || 0; + const usageValue = Number(usage) || 0; + + if (threshold > 0 && usageValue < threshold) return null; + + // Handle click event to open CPU monitor app + const onClick = + cpuMonitorApp === "None" + ? undefined + : (e) => { + Utils.clickEffect(e); + openCpuUsageApp(cpuMonitorApp); + }; + + if (displayAsGraph) { + return ( + + + + ); + } + + return ( + + {usage}% + + ); +}); + +Widget.displayName = "Cpu"; + +/** + * Open the specified CPU usage monitoring application + * @param {string} app - The name of the application to open + */ +function openCpuUsageApp(app) { + switch (app) { + case "Activity Monitor": + Uebersicht.run(`open -a "Activity Monitor"`); + break; + case "Top": + Utils.runInUserTerminal("top"); + break; + } +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/crypto.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/crypto.jsx new file mode 100755 index 0000000..3faf4ea --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/crypto.jsx @@ -0,0 +1,160 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { cryptoStyles as styles } from "../../styles/components/data/crypto"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 5 * 60 * 1000; // Default refresh frequency set to 5 minutes + +/** + * Crypto Widget Component + * @returns {JSX.Element|null} The Crypto Widget component + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, cryptoWidgetOptions } = settings; + const { cryptoWidget } = widgets; + const { + refreshFrequency, + denomination, + identifiers, + precision, + showOnDisplay, + showIcon, + } = cryptoWidgetOptions; + + // Calculate the refresh frequency using the provided or default value + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && cryptoWidget; + + const ref = React.useRef(); + const denominatorToken = getDenominatorToken(denomination); + + // Memoize cleanedUpIdentifiers to prevent recreation on every render + const cleanedUpIdentifiers = React.useMemo( + () => identifiers.replace(/ /g, ""), + [identifiers], + ); + + // Memoize enumeratedIdentifiers to prevent recreation on every render + const enumeratedIdentifiers = React.useMemo( + () => cleanedUpIdentifiers.replace(/ /g, "").split(","), + [cleanedUpIdentifiers], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + // Reset the widget state + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches cryptocurrency prices from the CoinGecko API + */ + const getCrypto = React.useCallback(async () => { + if (!visible) return; + const response = await fetch( + `https://api.coingecko.com/api/v3/simple/price?ids=${cleanedUpIdentifiers}&vs_currencies=${denomination}`, + ); + const result = await response.json(); + + const prices = enumeratedIdentifiers.map((id) => { + const value = parseFloat(result[id][denomination]).toFixed(precision); + return `${denominatorToken}${value}`; + }); + setState(prices); + setLoading(false); + }, [ + visible, + cleanedUpIdentifiers, + denomination, + enumeratedIdentifiers, + precision, + denominatorToken, + ]); + + useServerSocket("crypto", visible, getCrypto, resetWidget, setLoading); + useWidgetRefresh(visible, getCrypto, refresh); + + /** + * Refreshes the cryptocurrency prices + * @param {Event} e - The event object + */ + const refreshCrypto = (e) => { + Utils.clickEffect(e); + setLoading(true); + getCrypto(); + Utils.notification("Refreshing price from coingecko.com...", pushMissive); + }; + + if (loading) return ; + if (!state) return null; + + const classes = Utils.classNames("crypto"); + + return enumeratedIdentifiers.map((id, i) => ( + openCrypto(e, pushMissive)} + onRightClick={refreshCrypto} + > + {state[i]} + + )); +}); + +Widget.displayName = "Crypto"; + +/** + * Returns the icon component for a given cryptocurrency identifier + * @param {string} identifier - The cryptocurrency identifier + * @returns {JSX.Element} The icon component + */ +function getIcon(identifier) { + if (identifier === "celo") return Icons.Celo; + if (identifier === "ethereum") return Icons.Ethereum; + if (identifier === "bitcoin") return Icons.Bitcoin; + return Icons.Moon; +} + +/** + * Returns the symbol for a given denomination + * @param {string} denomination - The denomination (e.g., "usd", "eur") + * @returns {string} The symbol for the denomination + */ +function getDenominatorToken(denomination) { + if (denomination === "usd") return "$"; + if (denomination === "eur") return "€"; + return ""; +} + +/** + * Opens the cryptocurrency price chart + * @param {Event} e - The event object + * @param {Function} pushMissive - The function to push a notification + */ +function openCrypto(e, pushMissive) { + Utils.clickEffect(e); + Utils.notification("Opening price chart from coingecko.com...", pushMissive); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget-loader.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget-loader.jsx new file mode 100755 index 0000000..4115024 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget-loader.jsx @@ -0,0 +1,37 @@ +import * as Uebersicht from "uebersicht"; +import * as Utils from "../../utils"; + +export { dataWidgetLoaderStyles as styles } from "../../styles/components/data/data-widget-loader"; + +const { React } = Uebersicht; + +/** + * A memoized React component that renders a data widget loader. + * + * @param {Object} props - The properties object. + * @param {number} [props.width=14] - The width of the loader. + * @param {number} [props.height=14] - The height of the loader. + * @param {string} [props.className] - Additional class names for the loader. + * @param {Object} [props.style] - Additional styles for the loader. + * @returns {JSX.Element} The rendered data widget loader component. + */ +export const Widget = React.memo( + ({ width = 14, height = 14, className, style }) => { + // Generate class names using the Utils.classNames function + const classes = Utils.classNames("data-widget-loader", "data-widget", { + [className]: className, + }); + + // Return the JSX for the data widget loader + return ( +
+
+
+ ); + }, +); + +Widget.displayName = "DataWidgetLoader"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget.jsx new file mode 100755 index 0000000..f5555a1 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/data-widget.jsx @@ -0,0 +1,136 @@ +import * as Uebersicht from "uebersicht"; +import * as Specter from "./specter.jsx"; +import * as Utils from "../../utils"; +import { SuspenseIcon } from "../icons/icon.jsx"; +export { dataWidgetStyles as styles } from "../../styles/components/data/data-widget"; + +const { React } = Uebersicht; + +/** + * Widget component that renders a clickable data widget with optional icon and specter. + * @param {Object} props - The properties object. + * @param {React.Component} props.Icon - The icon component to display. + * @param {string} props.classes - Additional classes for the widget. + * @param {string} props.href - The URL to link to. + * @param {function} props.onClick - The click event handler. + * @param {function} props.onRightClick - The right-click event handler. + * @param {function} props.onMiddleClick - The middle-click event handler. + * @param {Object} props.style - The style object. + * @param {boolean} props.disableSlider - Flag to disable the slider effect. + * @param {boolean} props.showSpecter - Flag to show the specter widget. + * @param {boolean} props.useDivForClick - Render a div for click handling instead of a button. + * @param {React.ReactNode} props.children - The child elements to render inside the widget. + * @returns {React.ReactElement} The rendered widget component. + */ +export function Widget({ + Icon, + classes, + href, + onClick, + onRightClick, + onMiddleClick, + style, + disableSlider, + showSpecter, + useDivForClick, + children, +}) { + const ref = React.useRef(); + + let Tag = "div"; + if (href) Tag = "a"; + if (onClick && !href && !useDivForClick) Tag = "button"; + + const renderDivButton = Tag === "div" && Boolean(onClick); + + const dataWidgetClasses = Utils.classNames("data-widget", classes, { + "data-widget--clickable": href || onClick, + }); + + /** + * Handles the click event, determining the appropriate action based on the event. + * @param {MouseEvent} e - The mouse event. + */ + const onClickProp = (e) => { + const { metaKey } = e; + const action = metaKey || isMiddleClick(e) ? onMiddleClick : onClick; + if (action) action(e); + }; + + /** + * Handles the mouse enter event to start the sliding effect. + */ + const onMouseEnter = () => { + Utils.startSliding( + ref.current, + ".data-widget__inner", + ".data-widget__slider", + ); + }; + + /** + * Handles the mouse leave event to stop the sliding effect. + */ + const onMouseLeave = () => { + Utils.stopSliding(ref.current, ".data-widget__slider"); + }; + + /** + * Handles keyboard interaction for div-based clickable widgets. + * @param {KeyboardEvent} e - The keyboard event. + */ + const onKeyDown = (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + onClickProp(e); + }; + + return ( + + {Icon && ( + + + + )} + {showSpecter && } + {children} + + ); +} + +/** + * Inner component that optionally wraps children with sliding effect elements. + * @param {Object} props - The properties object. + * @param {boolean} props.disableSlider - Flag to disable the slider effect. + * @param {React.ReactNode} props.children - The child elements to render inside the inner component. + * @returns {React.ReactElement} The rendered inner component. + */ +function Inner({ disableSlider, children }) { + if (disableSlider) return children; + return ( +
+
{children}
+
+ ); +} + +/** + * Checks if the event is a middle-click. + * @param {MouseEvent} e - The mouse event. + * @returns {boolean} True if the event is a middle-click, false otherwise. + */ +function isMiddleClick(e) { + return e.button === 1 || e["button&2"] === 1; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/date-display.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/date-display.jsx new file mode 100755 index 0000000..af4c058 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/date-display.jsx @@ -0,0 +1,118 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import * as Utils from "../../utils"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; + +export { dateStyles as styles } from "../../styles/components/data/date-display"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 30000; + +/** + * Date display widget component. + * @returns {JSX.Element} The date display widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, dateWidgetOptions } = settings; + const { dateWidget } = widgets; + const { + refreshFrequency, + shortDateFormat, + locale, + calendarApp, + showOnDisplay, + showIcon, + } = dateWidgetOptions; + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && dateWidget; + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + const formatOptions = shortDateFormat ? "short" : "long"; + + // Memoize the date format options + const options = React.useMemo( + () => ({ + weekday: formatOptions, + month: formatOptions, + day: "numeric", + }), + [formatOptions], + ); + + // Ensure locale is valid, default to "en-UK" if not + const _locale = locale.length > 4 ? locale : "en-UK"; + + /** + * Reset the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Get the current date and update the state. + */ + const getDate = React.useCallback(() => { + if (!visible) return; + const now = new Date().toLocaleDateString(_locale, options); + setState({ now }); + setLoading(false); + }, [_locale, options, visible]); + + // Use server socket to get date updates + useServerSocket("date-display", visible, getDate, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getDate, refresh); + + if (loading) return ; + if (!state) return null; + const { now } = state; + + /** + * Handle click event to open the calendar application. + * @param {Event} e - The click event. + */ + const onClick = (e) => { + Utils.clickEffect(e); + openCalendarApp(calendarApp); + }; + + return ( + + {now} + + ); +}); + +Widget.displayName = "DateDisplay"; + +/** + * Open the specified calendar application. + * @param {string} calendarApp - The name of the calendar application to open. + */ +function openCalendarApp(calendarApp) { + const appName = calendarApp || "Calendar"; + Uebersicht.run(`open -a "${appName}"`); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/github.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/github.jsx new file mode 100755 index 0000000..89308b5 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/github.jsx @@ -0,0 +1,105 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh.js"; +import useServerSocket from "../../hooks/use-server-socket.js"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils.js"; + +export { githubStyles as styles } from "../../styles/components/data/github.js"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 1000 * 60 * 10; + +/** + * GitHub notification widget component + * @returns {JSX.Element|null} The GitHub notification widget component + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, githubWidgetOptions } = settings; + const { githubWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + hideWhenNoNotification, + notificationUrl, + ghBinaryPath, + showIcon, + } = githubWidgetOptions; + + const isDisabled = React.useRef(false); + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && githubWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch GPU data and update the widget state + */ + const getGitHub = React.useCallback(async () => { + if (!visible) return; + setLoading(true); + const result = await Utils.cachedRun( + `${ghBinaryPath} api notifications`, + refresh, + ); + if (!visible || isDisabled.current) { + return setLoading(false); + } + const json = JSON.parse(result); + const count = json.length; + setState({ count: count > 99 ? "99+" : count }); + setLoading(false); + }, [ghBinaryPath, visible, refresh]); + + // Update the disabled state based on visibility + React.useEffect(() => { + isDisabled.current = !visible; + }, [visible]); + + // Use server socket to fetch GPU data + useServerSocket("github", visible, getGitHub, resetWidget, setLoading); + // Use widget refresh hook to periodically refresh the widget + useWidgetRefresh(visible, getGitHub, refresh); + + if (loading) return ; + if (!state) return null; + + const { count } = state; + + if (hideWhenNoNotification && count === 0) return null; + + return ( + + {count} + + ); +}); + +Widget.displayName = "GitHub"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/gpu.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/gpu.jsx new file mode 100755 index 0000000..6ac1dab --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/gpu.jsx @@ -0,0 +1,128 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import Graph from "./graph.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh.js"; +import useServerSocket from "../../hooks/use-server-socket.js"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils.js"; + +export { gpuStyles as styles } from "../../styles/components/data/gpu.js"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 2000; +const GRAPH_LENGTH = 40; + +/** + * GPU Widget component + * @returns {JSX.Element|null} The GPU widget component + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, gpuWidgetOptions } = settings; + const { gpuWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + displayAsGraph, + gpuMacmonBinaryPath, + showIcon, + } = gpuWidgetOptions; + + const isDisabled = React.useRef(false); + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && gpuWidget; + + const [graph, setGraph] = React.useState([]); + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setGraph([]); + }; + + /** + * Fetch GPU data and update the widget state + */ + const getGpu = React.useCallback(async () => { + if (!visible) return; + try { + const result = await Utils.cachedRun( + `${gpuMacmonBinaryPath} pipe -s 1`, + refresh, + ); + if (!visible || isDisabled.current) { + return; + } + const json = JSON.parse(result); + const { gpu_usage } = json; + const formattedUsage = { usage: Math.round(gpu_usage[1] * 100) }; + setState(formattedUsage); + if (displayAsGraph) { + Utils.addToGraphHistory(formattedUsage, setGraph, GRAPH_LENGTH); + } + setLoading(false); + } catch { + setTimeout(getGpu, 1000); + } + }, [displayAsGraph, gpuMacmonBinaryPath, visible, refresh]); + + // Update the disabled state based on visibility + React.useEffect(() => { + isDisabled.current = !visible; + }, [visible]); + + // Use server socket to fetch GPU data + useServerSocket("gpu", visible, getGpu, resetWidget, setLoading); + // Use widget refresh hook to periodically refresh the widget + useWidgetRefresh(visible, getGpu, refresh); + + if (loading) return ; + if (!state) return null; + + const { usage } = state; + + if (displayAsGraph) { + return ( + + + + ); + } + + return ( + + {usage}% + + ); +}); + +Widget.displayName = "Gpu"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/graph.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/graph.jsx new file mode 100755 index 0000000..008e68e --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/graph.jsx @@ -0,0 +1,87 @@ +import * as Uebersicht from "uebersicht"; +import * as Utils from "../../utils.js"; +import { SuspenseIcon } from "../icons/icon.jsx"; + +const { React } = Uebersicht; + +export { graphStyles as styles } from "../../styles/components/data/graph"; + +/** + * Graph component to display a bar graph with captions and values. + * + * @param {Object} props - The properties object. + * @param {string} props.className - Additional class names for the graph container. + * @param {Object} props.caption - Caption data containing value, icon, and color for each key. + * @param {Array} props.values - Array of value objects to be displayed as bars. + * @param {number} props.maxLength - Maximum length of the graph. + * @param {number} [props.maxValue] - Maximum value for scaling the bars. + * @returns {JSX.Element|null} The rendered graph component or null if no caption is provided. + */ +export default function Graph({ + className, + caption, + values = [], + maxLength, + maxValue, +}) { + // Return null if no caption is provided + if (!caption) { + return null; + } + + const captionKeys = Object.keys(caption); + + // Calculate maxValue if not provided + if (maxValue === undefined) { + const allValues = values.map((value) => Object.values(value)).flat(); + maxValue = Math.max(...allValues); + } + + const classes = Utils.classNames("graph", className); + + return ( +
+
+ {values.map((value) => { + const keys = Object.keys(value); + return keys.map((key) => { + const height = (value[key] / maxValue) * 100; + const { color: backgroundColor } = caption[key]; + return ( +
+ ); + }); + })} +
+
+ {captionKeys.map((key) => { + const { value, icon: Icon, color } = caption[key]; + return ( +
+ {Icon && ( + + + + )} + +
+ ); + })} +
+
+ ); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/keyboard.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/keyboard.jsx new file mode 100755 index 0000000..d6e04f2 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/keyboard.jsx @@ -0,0 +1,108 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { keyboardStyles as styles } from "../../styles/components/data/keyboard"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 20000; + +/** + * Keyboard widget component. + * @returns {JSX.Element|null} The rendered widget or null if not visible. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, keyboardWidgetOptions } = settings; + const { keyboardWidget } = widgets; + const { refreshFrequency, showOnDisplay, showIcon, keyboardMaxLength } = + keyboardWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && keyboardWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches the current keyboard layout or input mode. + */ + const getKeyboard = React.useCallback(async () => { + if (!visible) return; + const keyboard = await Utils.cachedRun( + `defaults read ~/Library/Preferences/com.apple.HIToolbox.plist AppleSelectedInputSources | awk '/KeyboardLayout Name/ {$1=$2=$3=""; print $0}'`, + refresh, + ); + const layout = Utils.cleanupOutput(keyboard) + .replace(";", "") + .replaceAll('"', ""); + if (layout.length) { + setState({ keyboard: layout }); + setLoading(false); + return; + } + + const inputMode = await Utils.cachedRun( + `defaults read ~/Library/Preferences/com.apple.HIToolbox.plist AppleSelectedInputSources | awk '/"Input Mode" =/ {$1=$2=$3=""; print $0}'`, + refresh, + ); + const cleanedInputMode = Utils.cleanupOutput(inputMode) + .replace(/"com.apple.inputmethod.(.*)"/, "$1") + .replace(";", ""); + + if (!cleanedInputMode.length) return setLoading(false); + + const splitedInputMode = cleanedInputMode.split("."); + const inputModeName = splitedInputMode[splitedInputMode.length - 1]; + setState({ keyboard: inputModeName }); + setLoading(false); + }, [visible, refresh]); + + // Use server socket to listen for keyboard events + useServerSocket("keyboard", visible, getKeyboard, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getKeyboard, refresh); + + if (loading) return ; + if (!state) return null; + const { keyboard } = state; + + const maxLength = Number(keyboardMaxLength) || 0; + const displayKeyboard = + maxLength > 0 ? keyboard.slice(0, maxLength) : keyboard; + + if (!displayKeyboard?.length) return null; + + return ( + + {displayKeyboard} + + ); +}); + +Widget.displayName = "Keyboard"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/memory.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/memory.jsx new file mode 100755 index 0000000..e4361db --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/memory.jsx @@ -0,0 +1,134 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { memoryStyles as styles } from "../../styles/components/data/memory"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 4000; + +/** + * Memory Widget component + * @returns {JSX.Element|null} The memory widget component + */ +export const Widget = () => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, memoryWidgetOptions } = settings; + const { memoryWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + memoryMonitorApp, + showIcon, + memoryUsageThreshold, + } = memoryWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && memoryWidget; + + const [state, setState] = Uebersicht.React.useState(); + const [loading, setLoading] = Uebersicht.React.useState(visible); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch memory usage data + */ + const getMemory = React.useCallback(async () => { + const output = await Utils.cachedRun( + 'vm_stat | awk \'BEGIN {page_size=4096} /page size of/ {page_size=$8} /Pages free/ {free=$3} /Pages inactive/ {inactive=$3} /Pages speculative/ {spec=$4} /Pages active/ {active=$3} /Pages wired/ {wired=$4} END {gsub(/\\./, "", free); gsub(/\\./, "", inactive); gsub(/\\./, "", spec); gsub(/\\./, "", active); gsub(/\\./, "", wired); available=free+inactive+spec; total=available+active+wired; printf "%.0f", (available/total)*100}\'', + refresh, + ); + const free = parseInt(Utils.cleanupOutput(output), 10); + setState({ free }); + setLoading(false); + }, [setLoading, setState, refresh]); + + // Use server socket to get memory data + useServerSocket("memory", visible, getMemory, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getMemory, refresh); + + if (loading) return ; + if (!state) return null; + + const { free } = state; + const used = 100 - free; + const threshold = Number(memoryUsageThreshold) || 0; + + if (threshold > 0 && used < threshold) return null; + + // Handle click event to open memory usage app + const onClick = + memoryMonitorApp === "None" + ? undefined + : (e) => { + Utils.clickEffect(e); + openMemoryUsageApp(memoryMonitorApp); + }; + + /** + * Pie chart component for memory usage + * @returns {JSX.Element} The pie chart component + */ + const Pie = () => { + return ( +
+ ); + }; + + const classes = Utils.classNames("memory", { + "memory--low": used <= 30, + "memory--medium": used > 30 && used <= 70, + "memory--high": used > 70, + }); + + return ( + +
{used}%
+
+ ); +}; + +/** + * Open the specified memory usage application + * @param {string} app - The name of the application to open + */ +function openMemoryUsageApp(app) { + switch (app) { + case "Activity Monitor": + Uebersicht.run(`open -a "Activity Monitor"`); + break; + case "Top": + Utils.runInUserTerminal("top"); + break; + } +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mic.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mic.jsx new file mode 100755 index 0000000..5c071a8 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mic.jsx @@ -0,0 +1,152 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import * as Utils from "../../utils"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; + +const { React } = Uebersicht; + +export { micStyles as styles } from "../../styles/components/data/mic"; + +const DEFAULT_REFRESH_FREQUENCY = 20000; + +/** + * Mic widget component. + * @returns {JSX.Element} The rendered mic widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, micWidgetOptions } = settings; + const { micWidget } = widgets; + const { refreshFrequency, showOnDisplay, showIcon } = micWidgetOptions; + + // Determine the refresh frequency for the widget. + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible. + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && micWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const { volume: _volume } = state || {}; + const [volume, setVolume] = React.useState(_volume && parseInt(_volume, 10)); + const [dragging, setDragging] = React.useState(false); + + /** + * Reset the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch the current microphone volume. + */ + const getMic = React.useCallback(async () => { + if (!visible) return; + const volume = await Utils.cachedRun( + `osascript -e 'input volume of (get volume settings)'`, + refresh, + ); + setState({ volume: Utils.cleanupOutput(volume) }); + setLoading(false); + }, [visible, refresh]); + + // Use server socket to get mic data. + useServerSocket("mic", visible, getMic, resetWidget, setLoading); + // Refresh the widget periodically. + useWidgetRefresh(visible, getMic, refresh); + + // Update the mic volume when dragging state changes. + React.useEffect(() => { + if (!dragging) setMic(volume); + }, [dragging, volume]); + + // Update the volume state when the fetched volume changes. + React.useEffect(() => { + setVolume((currentVolume) => { + if (_volume && currentVolume !== parseInt(_volume, 10)) { + return parseInt(_volume, 10); + } + return currentVolume; + }); + }, [_volume]); + + if (loading) return ; + if (!state || volume === undefined || _volume === "missing value") + return null; + + const Icon = !volume ? Icons.MicOff : Icons.MicOn; + + /** + * Handle volume change event. + * @param {React.ChangeEvent} e - The change event. + */ + const onChange = (e) => { + const value = parseInt(e.target.value, 10); + setVolume(value); + }; + + /** + * Handle mouse down event on the slider. + */ + const onMouseDown = () => setDragging(true); + + /** + * Handle mouse up event on the slider. + */ + const onMouseUp = () => setDragging(false); + + const formattedVolume = `${volume.toString().padStart(2, "0")}%`; + + const classes = Utils.classNames("mic", { + "mic--dragging": dragging, + }); + + return ( + +
+ {showIcon && ( + + + + )} + {formattedVolume} +
+
+ +
+
+ ); +}); + +Widget.displayName = "Mic"; + +/** + * Set the microphone volume. + * @param {number} volume - The volume level to set. + */ +function setMic(volume) { + if (volume === undefined) return; + Uebersicht.run(`osascript -e 'set volume input volume ${volume}'`); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mpd.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mpd.jsx new file mode 100755 index 0000000..53449b1 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/mpd.jsx @@ -0,0 +1,228 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { mpdStyles as styles } from "../../styles/components/data/mpd"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * MPD Widget component + * @returns {JSX.Element|null} The MPD widget + */ +export const Widget = React.memo(() => { + const ref = React.useRef(); + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, mpdWidgetOptions } = settings; + const { mpdWidget } = widgets; + const { + refreshFrequency, + showSpecter, + mpdBinaryPath, + mpdHost, + mpdPort, + mpdFormatString, + showOnDisplay, + showIcon, + } = mpdWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && mpdWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const [isMpdActive, setIsMpdActive] = React.useState(false); + const { volume: _volume } = state || {}; + const defaultVolume = _volume && parseInt(_volume); + const [volume, setVolume] = React.useState(defaultVolume); + const [dragging, setDragging] = React.useState(false); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setIsMpdActive(false); + }; + + /** + * Fetch MPD data + */ + const getMpd = React.useCallback(async () => { + if (!visible) return; + + try { + const mpdProcess = await Utils.cachedRun( + `pgrep -x mpd > /dev/null && echo "true" || echo "false"`, + refresh, + ); + + if (Utils.cleanupOutput(mpdProcess) === "false") { + setLoading(false); + setIsMpdActive(false); + return; + } + + const [playerState, trackInfo, volumeState] = await Promise.all([ + Utils.cachedRun( + `${mpdBinaryPath} --host ${mpdHost} --port ${mpdPort} | head -n 2 | tail -n 1 | awk '{print substr($1,2,length($1)-2)}' 2>/dev/null || echo "stopped"`, + refresh, + ), + Utils.cachedRun( + `${mpdBinaryPath} --host ${mpdHost} --port ${mpdPort} --format "${mpdFormatString}" | head -n 1`, + refresh, + ), + Utils.cachedRun( + `${mpdBinaryPath} --host ${mpdHost} --port ${mpdPort} volume | sed -e 's/volume:[ ]*//g' -e 's/%//g'`, + refresh, + ), + ]); + if (Utils.cleanupOutput(trackInfo) === "") { + setLoading(false); + setIsMpdActive(false); + return; + } + setState({ + playerState: Utils.cleanupOutput(playerState), + trackInfo: Utils.cleanupOutput(trackInfo), + volume: Utils.cleanupOutput(volumeState), + }); + setIsMpdActive(true); + setLoading(false); + } catch { + setLoading(false); + setIsMpdActive(false); + } + }, [visible, mpdBinaryPath, mpdHost, mpdPort, mpdFormatString, refresh]); + + // Update the volume when dragging ends + React.useEffect(() => { + if (!dragging) setSound(mpdBinaryPath, mpdHost, mpdPort, volume); + }, [dragging, mpdBinaryPath, mpdHost, mpdPort, volume]); + + // Set the volume state when dragging ends + React.useEffect(() => { + if (!dragging) setVolume(volume); + }, [dragging, volume]); + + useServerSocket("mpd", visible, getMpd, resetWidget, setLoading); + useWidgetRefresh(visible, getMpd, refresh); + + if (loading) return ; + if (!state || !isMpdActive) return null; + const { playerState, trackInfo } = state; + + if (!trackInfo.length) return null; + + const isPlaying = playerState === "playing"; + const Icon = isPlaying ? Icons.Playing : Icons.Paused; + + /** + * Handle click event to toggle play/pause + * @param {React.MouseEvent} e - The click event + */ + const onClick = async (e) => { + Utils.clickEffect(e); + await togglePlay(mpdBinaryPath, mpdHost, mpdPort); + await getMpd(); + }; + + /** + * Handle volume change event + * @param {React.ChangeEvent} e - The change event + */ + const onChange = (e) => { + const value = parseInt(e.target.value); + setVolume(value); + }; + + const onMouseDown = () => setDragging(true); + + const onMouseUp = () => setDragging(false); + + const stopPropagation = (e) => e.stopPropagation(); + + const onMouseEnter = () => + Utils.startSliding(ref.current, ".mpd__inner", ".mpd__info"); + const onMouseLeave = () => Utils.stopSliding(ref.current, ".mpd__slider"); + + const classes = Utils.classNames("sound", "mpd", { + "mpd--playing": isPlaying, + "mpd--dragging": dragging, + }); + + return ( + +
+
+
{trackInfo}
+
+
+ +
+
+
+ ); +}); + +Widget.displayName = "Mpd"; + +/** + * Toggle play/pause state of MPD + * @param {string} path - The path to the MPD binary + * @param {string} host - The MPD host + * @param {string} port - The MPD port + * @returns {Promise} + */ +async function togglePlay(path, host, port) { + return Uebersicht.run(`${path} --host ${host} --port ${port} toggle`); +} + +/** + * Set the volume of MPD + * @param {string} path - The path to the MPD binary + * @param {string} host - The MPD host + * @param {string} port - The MPD port + * @param {number} volume - The volume level to set + */ +function setSound(path, host, port, volume) { + if (!volume) return; + Uebersicht.run(`${path} --host ${host} --port ${port} volume ${volume}`); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/music.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/music.jsx new file mode 100755 index 0000000..f0fabd6 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/music.jsx @@ -0,0 +1,161 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { musicStyles as styles } from "../../styles/components/data/music"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * Music widget component. + * @returns {JSX.Element|null} The music widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, musicWidgetOptions } = settings; + const { musicWidget } = widgets; + const { refreshFrequency, showSpecter, showOnDisplay, showIcon } = + musicWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && musicWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const [isMusicActive, setIsMusicActive] = React.useState(false); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setIsMusicActive(false); + }; + + /** + * Fetches the current music information. + */ + const getMusic = React.useCallback(async () => { + if (!visible) return; + const osVersion = await Utils.cachedRun(`sw_vers -productVersion`, refresh); + const processName = + Utils.cleanupOutput(osVersion) === "10.15" ? "iTunes" : "Music"; + const isRunning = await Utils.cachedRun( + `pgrep -xq "${processName}" && echo "true" || echo "false"`, + refresh, + ); + if (Utils.cleanupOutput(isRunning) === "false") { + setLoading(false); + setIsMusicActive(false); + return; + } + const output = await Utils.cachedRun( + `osascript -e 'tell application "${processName}"' -e 'set output to (player state as string) & "|" & (name of current track as string) & "|" & (artist of current track as string)' -e 'end tell' 2>/dev/null || echo "stopped|unknown track|unknown artist"`, + refresh, + ); + const [playerState, trackName, artistName] = + Utils.cleanupOutput(output).split("|"); + setState({ + playerState, + trackName, + artistName, + processName: Utils.cleanupOutput(processName), + }); + setIsMusicActive(true); + setLoading(false); + }, [visible, refresh]); + + // Use server socket to listen for music events + useServerSocket("music", visible, getMusic, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getMusic, refresh); + + if (loading) return ; + if (!state || !isMusicActive) return null; + const { processName, playerState, trackName, artistName } = state; + + if (!trackName.length) return null; + + const isPlaying = playerState === "playing"; + const Icon = isPlaying ? Icons.Playing : Icons.Paused; + + /** + * Handles click event to toggle play/pause. + * @param {React.MouseEvent} e - The click event. + */ + const onClick = (e) => { + Utils.clickEffect(e); + togglePlay(!isPlaying, processName); + getMusic(); + }; + + /** + * Handles right-click event to skip to the next track. + * @param {React.MouseEvent} e - The right-click event. + */ + const onRightClick = (e) => { + Utils.clickEffect(e); + Uebersicht.run( + `osascript -e 'tell application "${processName}" to Next Track'`, + ); + getMusic(); + }; + + /** + * Handles middle-click event to open the music application. + * @param {React.MouseEvent} e - The middle-click event. + */ + const onMiddleClick = (e) => { + Utils.clickEffect(e); + Uebersicht.run(`open -a '${processName}'`); + getMusic(); + }; + + const classes = Utils.classNames("music", { + "music--playing": isPlaying, + }); + + return ( + + {trackName} - {artistName} + + ); +}); + +Widget.displayName = "Music"; + +/** + * Toggles play/pause state of the music application. + * @param {boolean} isPaused - Whether the music is paused. + * @param {string} processName - The name of the music application process. + */ +function togglePlay(isPaused, processName) { + if (isPaused) { + Uebersicht.run(`osascript -e 'tell application "${processName}" to play'`); + } else { + Uebersicht.run(`osascript -e 'tell application "${processName}" to pause'`); + } +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/netstats.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/netstats.jsx new file mode 100755 index 0000000..ef3d7be --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/netstats.jsx @@ -0,0 +1,182 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import Graph from "./graph.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh.js"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils.js"; + +export { netstatsStyles as styles } from "../../styles/components/data/netstats"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 2000; +const GRAPH_LENGTH = 30; + +/** + * Netstats widget component. + * @returns {JSX.Element|null} The rendered component. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, netstatsWidgetOptions } = settings; + const { netstatsWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + displayAsGraph, + showIcon, + netstatsThreshold, + } = netstatsWidgetOptions; + + const isDisabled = React.useRef(false); + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && netstatsWidget; + + const [graph, setGraph] = React.useState([]); + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setGraph([]); + }; + + /** + * Fetches network statistics. + */ + const getNetstats = React.useCallback(async () => { + if (!visible) return; + try { + const output = await Utils.cachedRun( + `bash ./simple-bar/lib/scripts/netstats.sh 2>&1`, + refresh, + ); + if (!visible || isDisabled.current) { + return; + } + const data = Utils.cleanupOutput(output); + const json = JSON.parse(data); + setState(json); + if (displayAsGraph) { + Utils.addToGraphHistory(json, setGraph, GRAPH_LENGTH); + } + setLoading(false); + } catch { + setTimeout(getNetstats, 1000); + } + }, [displayAsGraph, setGraph, visible, refresh]); + + // Update the disabled state based on visibility + React.useEffect(() => { + isDisabled.current = !visible; + }, [visible]); + + // Set up server socket and widget refresh hooks + useServerSocket("netstats", visible, getNetstats, resetWidget, setLoading); + useWidgetRefresh(visible, getNetstats, refresh); + + if (loading) + return ( + + + {!displayAsGraph && } + + ); + + if (!state) { + return null; + } + + const { download, upload } = state; + + if (download === undefined || upload === undefined) { + return null; + } + + const threshold = (Number(netstatsThreshold) || 0) * 1024; + const isBelowThreshold = + threshold > 0 && + Math.abs(download) < threshold && + Math.abs(upload) < threshold; + + if (isBelowThreshold) { + return null; + } + + const formattedDownload = Utils.formatBytes(download); + const formattedUpload = Utils.formatBytes(upload); + + if (displayAsGraph) { + return ( + + + + ); + } + + return ( + + +
+ {showIcon && ( + + + + )} + +
+
+ +
+ {showIcon && ( + + + + )} + +
+
+
+ ); +}); + +Widget.displayName = "Netstats"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx new file mode 100755 index 0000000..74f6bf8 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx @@ -0,0 +1,310 @@ +/** + * Next Meeting Widget + * + * Displays the next upcoming calendar event with time until start and optional join button. + * Uses icalBuddy for fast calendar access with proper recurring event support. + * + * Requirements: + * - macOS with Calendar.app or compatible calendar + * - icalBuddy installed (brew install ical-buddy) + * - Calendar access permissions granted + * + * @module next-meeting + */ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { nextMeetingStyles as styles } from "../../styles/components/data/next-meeting"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 60000; // 1 minute + +// Timing thresholds (in minutes) +const URGENT_THRESHOLD = 5; // Red pulsing when ≤5 min until meeting +const UPCOMING_THRESHOLD = 15; // Yellow when ≤15 min until meeting + +// Uses icalBuddy for fast calendar access (handles recurring events properly) +// Note: Shell script automatically skips meetings that started more than 15 minutes ago +const BASE_COMMAND = `./simple-bar/lib/scripts/next-meeting.sh`; + +/** + * Builds the shell command with optional custom icalBuddy path and look-ahead hours. + * @param {string} icalBuddyPath - Optional custom path to icalBuddy binary + * @param {number} lookAheadHours - Hours to look ahead for meetings (default: 12) + * @returns {string} Complete shell command + */ +function buildCommand(icalBuddyPath, lookAheadHours) { + const hours = lookAheadHours || 12; + if (icalBuddyPath && icalBuddyPath.trim()) { + // Escape special shell characters to prevent injection + const safePath = icalBuddyPath.replace(/["$`\\]/g, "\\$&"); + return `${BASE_COMMAND} "${safePath}" ${hours}`; + } + return `${BASE_COMMAND} "" ${hours}`; +} + +// Patterns to extract meeting URLs from notes +const MEETING_URL_PATTERNS = [ + /https?:\/\/[\w-]*\.?zoom\.us\/[^\s<>"]+/gi, + /https?:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s<>"]+/gi, + /https?:\/\/meet\.google\.com\/[^\s<>"]+/gi, + /https?:\/\/[\w-]*\.?webex\.com\/[^\s<>"]+/gi, +]; + +// SafeLinks pattern (Microsoft Outlook protection wrapper) +const SAFELINKS_PATTERN = + /https?:\/\/[\w.-]*safelinks\.protection\.outlook\.com\/[^\s<>">\]]+/gi; + +/** + * Decode a URL that may be URL-encoded + * @param {string} url - Possibly encoded URL + * @returns {string} Decoded URL + */ +function decodeUrl(url) { + try { + return decodeURIComponent(url); + } catch { + return url; + } +} + +/** + * Extract the real URL from a SafeLink wrapper + * @param {string} safeLink - SafeLink URL + * @returns {string} Extracted real URL or original + */ +function extractFromSafeLink(safeLink) { + const urlParam = safeLink.match(/[?&]url=([^&]+)/i); + if (urlParam && urlParam[1]) { + return decodeUrl(urlParam[1]); + } + return safeLink; +} + +/** + * Extract meeting URL from event URL or notes + * @param {string} url - Event URL + * @param {string} notes - Event notes/description + * @returns {string|null} Meeting URL or null + */ +function extractMeetingUrl(url, notes) { + // First check direct URL + if (url && url.trim() && url !== "missing value") { + for (const pattern of MEETING_URL_PATTERNS) { + pattern.lastIndex = 0; + const match = url.match(pattern); + if (match) return match[0]; + } + if (url.startsWith("http")) return url; + } + + // Check notes for meeting links + if (notes && notes.trim()) { + // First try direct meeting URLs + for (const pattern of MEETING_URL_PATTERNS) { + pattern.lastIndex = 0; + const match = notes.match(pattern); + if (match) return match[0]; + } + + // Check for SafeLinks that contain meeting URLs + SAFELINKS_PATTERN.lastIndex = 0; + const safeLinks = notes.match(SAFELINKS_PATTERN); + if (safeLinks) { + for (const safeLink of safeLinks) { + const realUrl = extractFromSafeLink(safeLink); + for (const pattern of MEETING_URL_PATTERNS) { + pattern.lastIndex = 0; + if (pattern.test(realUrl)) { + return realUrl; + } + } + } + } + } + + return null; +} + +/** + * Format time until meeting + * @param {number} minutes - Minutes until meeting + * @returns {string} Formatted time string + */ +function formatTimeUntil(minutes) { + if (minutes < 0) return "now"; + if (minutes < 1) return "<1m"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} + +/** + * Parse meeting data from shell script output + * @param {string} output - Raw command output + * @returns {Object|null} Meeting object or null + */ +function parseMeetingData(output) { + if (!output || !output.trim()) return null; + + const parts = output.trim().split("|"); + if (parts.length < 5) return null; + + const [title, startTime, url, notes, minutesUntil] = parts; + if (!title) return null; + + const meetingUrl = extractMeetingUrl(url, notes); + + return { + title: title.trim(), + startTime: startTime, + url: meetingUrl, + minutesUntil: parseInt(minutesUntil, 10) || 0, + }; +} + +/** + * Join button sub-component for meeting URLs. + * @component + * @param {Object} props - Component props + * @param {string} props.url - Meeting URL to open + * @returns {JSX.Element} Join button element + */ +const JoinButton = React.memo(({ url }) => { + /** + * Opens the meeting URL when clicked. + * @param {React.MouseEvent} e - Click event + */ + const handleJoin = async (e) => { + e.stopPropagation(); + Utils.clickEffect(e); + // Escape special shell characters to prevent injection + const safeUrl = url.replace(/["$`\\]/g, "\\$&"); + await Uebersicht.run(`open "${safeUrl}"`); + }; + + return ( + + ); +}); + +JoinButton.displayName = "JoinButton"; + +/** + * Next Meeting widget component + * Shows the next upcoming meeting with optional join button + * @returns {JSX.Element|null} The next meeting widget + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, nextMeetingWidgetOptions, dateWidgetOptions } = settings; + const { nextMeetingWidget } = widgets; + const { + refreshFrequency, + showOnDisplay, + showJoinButton, + showTimeOnly, + icalBuddyPath, + lookAheadHours, + } = nextMeetingWidgetOptions; + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && nextMeetingWidget; + + const [state, setState] = React.useState(null); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state. + */ + const resetWidget = React.useCallback(() => { + setState(null); + setLoading(false); + }, []); + + /** + * Fetches next meeting and updates the state. + */ + const getMeeting = React.useCallback(async () => { + if (!visible) return; + try { + const command = buildCommand(icalBuddyPath, lookAheadHours); + const output = await Uebersicht.run(command); + const meeting = parseMeetingData(output); + setState(meeting); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching next meeting:", error); + setState(null); + } + setLoading(false); + }, [visible, icalBuddyPath, lookAheadHours]); + + // Server socket for real-time updates + useServerSocket("next-meeting", visible, getMeeting, resetWidget, setLoading); + + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getMeeting, refresh); + + if (loading) return ; + if (!state) return null; + + const { title, minutesUntil, url } = state; + const timeDisplay = formatTimeUntil(minutesUntil); + const isUrgent = minutesUntil <= URGENT_THRESHOLD; // Red pulsing: ≤5 min or in progress (up to 15 min) + const isUpcoming = minutesUntil <= UPCOMING_THRESHOLD; // Yellow: ≤15 min (but not urgent/in-progress) + + const widgetClasses = Utils.classNames("next-meeting", { + "next-meeting--urgent": isUrgent, + "next-meeting--upcoming": isUpcoming && !isUrgent, + }); + + /** + * Handle click event to open the calendar application + * @param {Event} e - The click event + */ + const openCalendar = async (e) => { + Utils.clickEffect(e); + const calendarApp = dateWidgetOptions?.calendarApp || "Calendar"; + await Uebersicht.run(`open -a "${calendarApp}"`); + }; + + return ( + + {!showTimeOnly && {title}} + {timeDisplay} + {showJoinButton && url && } + + ); +}); + +Widget.displayName = "NextMeeting"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/notifications.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/notifications.jsx new file mode 100755 index 0000000..b55e487 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/notifications.jsx @@ -0,0 +1,189 @@ +/** + * Notifications Widget + * + * Displays badge counts from running applications with active notifications. + * Uses macOS lsappinfo to query app status labels (dock badges). + * + * Requirements: + * - macOS (lsappinfo is macOS-specific) + * - No special permissions required + * + * @module notifications + */ +import * as Uebersicht from "uebersicht"; +import * as AppIcons from "../../app-icons.js"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { notificationsStyles as styles } from "../../styles/components/data/notifications"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +// Shell command to get notification badges from running apps +const COMMAND = `lsappinfo -all list 2>/dev/null | perl -0777 -pe 's/---/\\n---\\n/g' | perl -ne ' + if (/"CFBundleName"="([^"]+)"/) { $app = $1; } + if (/"StatusLabel"=\\{ "label"="([^"]*)"/) { $badge = $1; } + if (/"LSBundlePath"="([^"]+)"/) { $path = $1; } + if (/^---$/ || eof) { + print "$app|$badge|$path\\n" if ($app && $badge); + ($app, $badge, $path) = ("", "", ""); + } +'`; + +/** + * Parses shell command output into notification objects. + * @param {string} output - Raw command output from lsappinfo + * @returns {Array<{name: string, badge: string, bundlePath: string}>} Array of notification objects + */ +function parseNotifications(output) { + if (!output || !output.trim()) return []; + + return output + .trim() + .split("\n") + .filter((line) => line.includes("|")) + .map((line) => { + const [name, badge, bundlePath] = line.split("|"); + return { name, badge, bundlePath }; + }) + .filter((item) => item.name && item.badge); +} + +/** + * Single app notification pill component. + * @component + * @param {Object} props - Component props + * @param {Object} props.app - Application notification data + * @param {string} props.app.name - Application name + * @param {string} props.app.badge - Badge count + * @param {string} props.app.bundlePath - Path to application bundle + * @returns {JSX.Element} Notification pill button + */ +const NotificationPill = React.memo(({ app }) => { + /** + * Opens the application when pill is clicked. + * @param {React.MouseEvent} e - Click event + */ + const openApp = async (e) => { + Utils.clickEffect(e); + // Escape special shell characters to prevent injection + const safePath = app.bundlePath.replace(/["$`\\]/g, "\\$&"); + await Uebersicht.run(`open "${safePath}"`); + }; + + // Get the SVG icon for this app, or use Default + const Icon = AppIcons.apps[app.name] || AppIcons.apps.Default; + + return ( + + ); +}); + +NotificationPill.displayName = "NotificationPill"; + +/** + * Notification Badges widget component. + * Shows each app with notifications as an inline pill. + * @component + * @returns {JSX.Element|null} The notifications widget component or null if no notifications + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, notificationsWidgetOptions } = settings; + const { notificationsWidget } = widgets; + const { refreshFrequency, showOnDisplay, excludedApps } = notificationsWidgetOptions; + + // Determine if the widget should be visible based on display settings + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && + notificationsWidget; + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [state, setState] = React.useState([]); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state. + */ + const resetWidget = React.useCallback(() => { + setState([]); + setLoading(false); + }, []); + + // Build the exclusion list from the comma-separated setting + const exclusionList = React.useMemo( + () => + excludedApps + ? excludedApps + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + : [], + [excludedApps], + ); + + /** + * Fetches notification badges and updates the state. + */ + const getNotifications = React.useCallback(async () => { + if (!visible) return; + try { + const output = await Utils.cachedRun(COMMAND, refresh); + const notifications = parseNotifications(output).filter( + (item) => !exclusionList.includes(item.name.toLowerCase()), + ); + setState(notifications); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching notifications:", error); + setState([]); + } + setLoading(false); + }, [visible, refresh, exclusionList]); + + // Server socket for real-time updates + useServerSocket( + "notifications", + visible, + getNotifications, + resetWidget, + setLoading, + ); + + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getNotifications, refresh); + + // Don't render anything if loading or no notifications + if (loading || !state.length) return null; + + return ( +
+ {state.map((app) => ( + + ))} +
+ ); +}); + +Widget.displayName = "Notifications"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/sound.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/sound.jsx new file mode 100755 index 0000000..7301426 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/sound.jsx @@ -0,0 +1,154 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { soundStyles as styles } from "../../styles/components/data/sound"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 20000; + +/** + * Sound widget component. + * @returns {JSX.Element|null} The sound widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, soundWidgetOptions } = settings; + const { soundWidget } = widgets; + const { refreshFrequency, showOnDisplay, showIcon } = soundWidgetOptions; + + // Determine the refresh frequency for the widget. + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible on the current display. + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && soundWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const { volume: _volume } = state || {}; + const [volume, setVolume] = React.useState(_volume && parseInt(_volume, 10)); + const [dragging, setDragging] = React.useState(false); + + /** + * Reset the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch the current sound settings. + */ + const getSound = React.useCallback(async () => { + if (!visible) return; + const output = await Utils.cachedRun( + `osascript -e 'set v to get volume settings' -e 'output volume of v & output muted of v'`, + refresh, + ); + const parts = Utils.cleanupOutput(output).split(","); + setState({ + volume: parts[0], + muted: parts[1], + }); + setLoading(false); + }, [visible, refresh]); + + // Use server socket to listen for sound updates. + useServerSocket("sound", visible, getSound, resetWidget, setLoading); + // Refresh the widget at the specified interval. + useWidgetRefresh(visible, getSound, refresh); + + // Update the sound settings when dragging ends. + React.useEffect(() => { + if (!dragging) setSound(volume); + }, [dragging, volume]); + + // Update the volume state when the fetched volume changes. + React.useEffect(() => { + setVolume((currentVolume) => { + if (_volume && currentVolume !== parseInt(_volume, 10)) { + return parseInt(_volume, 10); + } + return currentVolume; + }); + }, [_volume]); + + if (loading) return ; + if (!state || volume === undefined) return null; + + const { muted } = state; + if (_volume === "missing value" || muted === "missing value") return null; + + let Icon = Icons.VolumeHigh; + if (volume < 50) Icon = Icons.VolumeLow; + if (volume < 20) Icon = Icons.NoVolume; + if (muted === "true" || !volume) Icon = Icons.VolumeMuted; + + /** + * Handle volume change event. + * @param {React.ChangeEvent} e - The change event. + */ + const onChange = (e) => { + const value = parseInt(e.target.value, 10); + setVolume(value); + }; + + const onMouseDown = () => setDragging(true); + const onMouseUp = () => setDragging(false); + + const formattedVolume = `${volume.toString().padStart(2, "0")}%`; + + const classes = Utils.classNames("sound", { + "sound--dragging": dragging, + }); + + return ( + +
+ {showIcon && ( + + + + )} + {formattedVolume} +
+
+ +
+
+ ); +}); + +Widget.displayName = "Sound"; + +/** + * Set the system volume. + * @param {number} volume - The volume to set. + */ +function setSound(volume) { + if (volume === undefined) return; + Uebersicht.run(`osascript -e 'set volume output volume ${volume}'`); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/specter.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/specter.jsx new file mode 100755 index 0000000..66b0850 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/specter.jsx @@ -0,0 +1,24 @@ +import * as Uebersicht from "uebersicht"; + +export { specterStyles as styles } from "../../styles/components/data/specter"; + +const { React } = Uebersicht; + +/** + * Specter widget component + * + * This component renders a div with six span elements inside it. These are animated in CSS. + * + * @returns {JSX.Element} The rendered widget component + */ +export const Widget = React.memo(() => { + return ( +
+ {[...new Array(6)].map((_, i) => ( + + ))} +
+ ); +}); + +Widget.displayName = "Specter"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/spotify.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/spotify.jsx new file mode 100755 index 0000000..bc9f6e6 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/spotify.jsx @@ -0,0 +1,189 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Icons from "../icons/icons.jsx"; +import * as Utils from "../../utils"; + +export { spotifyStyles as styles } from "../../styles/components/data/spotify"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * Spotify widget component. + * @returns {JSX.Element|null} The Spotify widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, spotifyWidgetOptions } = settings; + const { spotifyWidget } = widgets; + const { + refreshFrequency, + showSpecter, + showOnDisplay, + showIcon, + showSpotifyMetadata, + } = spotifyWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && spotifyWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const [isSpotifyActive, setIsSpotifyActive] = React.useState(false); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setIsSpotifyActive(false); + }; + + /** + * Fetches the current Spotify state. + */ + const getSpotify = React.useCallback(async () => { + if (!visible) return; + const isRunning = await Utils.cachedRun( + `pgrep -fq '[S]potify Helper' && echo "true" || echo "false"`, + refresh, + ); + if (Utils.cleanupOutput(isRunning) === "false") { + setLoading(false); + setIsSpotifyActive(false); + setState({ + playerState: "", + trackName: "", + artistName: "", + }); + return; + } + // if showSpotifyMetadata is enabled, retrieves all information + if (showSpotifyMetadata) { + const output = await Utils.cachedRun( + `osascript -e 'tell application "Spotify"' -e 'set output to (player state as string) & "|" & (name of current track as string) & "|" & (artist of current track as string)' -e 'end tell' 2>/dev/null || echo "stopped||"`, + refresh, + ); + const [playerState, trackName, artistName] = + Utils.cleanupOutput(output).split("|"); + setState({ + playerState, + trackName, + artistName, + }); + // else, only playerState + } else { + const playerState = await Utils.cachedRun( + `osascript -e 'tell application "Spotify" to player state as string' 2>/dev/null || echo "stopped"`, + refresh, + ); + setState({ + playerState: Utils.cleanupOutput(playerState), + trackName: "", + artistName: "", + }); + } + + setIsSpotifyActive(true); + setLoading(false); + }, [visible, showSpotifyMetadata, refresh]); + + // Set up server socket and widget refresh hooks + useServerSocket("spotify", visible, getSpotify, resetWidget, setLoading); + useWidgetRefresh(visible, getSpotify, refresh); + + if (loading) return ; + if (!state || !isSpotifyActive) return null; + const { playerState, trackName, artistName } = state; + + if (!trackName.length && showSpotifyMetadata) return null; + + const label = artistName.length ? `${trackName} - ${artistName}` : trackName; + const isPlaying = playerState === "playing"; + const Icon = getIcon(playerState); + + /** + * Handles click event to toggle play/pause. + * @param {React.MouseEvent} e - The click event. + */ + const onClick = (e) => { + Utils.clickEffect(e); + togglePlay(!isPlaying); + getSpotify(); + }; + + /** + * Handles right-click event to skip to the next track. + * @param {React.MouseEvent} e - The right-click event. + */ + const onRightClick = (e) => { + Utils.clickEffect(e); + Uebersicht.run(`osascript -e 'tell application "Spotify" to Next Track'`); + getSpotify(); + }; + + /** + * Handles middle-click event to open Spotify. + * @param {React.MouseEvent} e - The middle-click event. + */ + const onMiddleClick = (e) => { + Utils.clickEffect(e); + Uebersicht.run(`open -a 'Spotify'`); + getSpotify(); + }; + + const classes = Utils.classNames("spotify", { + "spotify--hidden-metadata": !showSpotifyMetadata, + "spotify--playing": isPlaying, + }); + + return ( + + {showSpotifyMetadata && label} + + ); +}); + +Widget.displayName = "Spotify"; + +/** + * Toggles play/pause state of Spotify. + * @param {boolean} isPaused - Whether the player is paused. + */ +function togglePlay(isPaused) { + const state = isPaused ? "play" : "pause"; + Uebersicht.run(`osascript -e 'tell application "Spotify" to ${state}'`); +} + +/** + * Gets the appropriate icon based on the player state. + * @param {string} playerState - The current state of the player. + * @returns {JSX.Element} The icon component. + */ +function getIcon(playerState) { + if (playerState === "stopped") return Icons.Stopped; + if (playerState === "playing") return Icons.Playing; + return Icons.Paused; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/stock.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/stock.jsx new file mode 100755 index 0000000..4327f1c --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/stock.jsx @@ -0,0 +1,211 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { stockStyles as styles } from "../../styles/components/data/stock"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 15 * 60 * 1000; // 15 min + +/** + * Stock widget component + * @returns {JSX.Element|null} The stock widget + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, stockWidgetOptions } = settings; + const { stockWidget } = widgets; + const { + refreshFrequency, + yahooFinanceApiKey, + symbols, + showSymbol, + showCurrency, + showMarketPrice, + showMarketChange, + showMarketPercent, + showColor, + showOnDisplay, + showIcon, + } = stockWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Check if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && stockWidget; + + const ref = React.useRef(); + + // Memoize cleanedUpSymbols to prevent recreation on every render + const cleanedUpSymbols = React.useMemo( + () => symbols.replace(/ /g, ""), + [symbols], + ); + + // Memoize enumeratedSymbols to prevent recreation on every render + const enumeratedSymbols = React.useMemo( + () => cleanedUpSymbols.replace(/ /g, "").split(","), + [cleanedUpSymbols], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Reset the widget state + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetch stock data from Yahoo Finance API + */ + const getStocks = React.useCallback(async () => { + if (!visible) return; + const response = await fetch( + `https://yfapi.net/v6/finance/quote?symbols=${cleanedUpSymbols}`, + { + headers: new Headers({ "x-api-key": yahooFinanceApiKey }), + }, + ); + if (response.status === 429) { + // Exceeded daily quota + } else if (response.status === 403) { + Utils.notification("Invalid Yahoo Finance API key", pushMissive); + } else if (response.status === 200) { + const result = await response.json(); + const symbolQuotes = result.quoteResponse.result; + + if (symbolQuotes.length !== enumeratedSymbols.length) { + // One or more symbols is invalid + const receivedSymbols = symbolQuotes.map((s) => s.symbol.toLowerCase()); + const invalidSymbols = enumeratedSymbols.filter( + (s) => !receivedSymbols.includes(s), + ); + Utils.notification("Invalid stock symbol(s): " + invalidSymbols); + } else { + setState({ symbolQuotes }); + } + } else { + // eslint-disable-next-line no-console + console.error("Unexpected response from Yahoo Finance API: ", response); + } + + setLoading(false); + }, [ + visible, + cleanedUpSymbols, + yahooFinanceApiKey, + pushMissive, + enumeratedSymbols, + ]); + + // Use server socket to fetch stock data + useServerSocket("stock", visible, getStocks, resetWidget, setLoading); + // Refresh widget data periodically + useWidgetRefresh(visible, getStocks, refresh); + + /** + * Refresh stock data on user interaction + * @param {Event} e - The event object + */ + const refreshStocks = (e) => { + Utils.clickEffect(e); + setLoading(true); + getStocks(); + }; + + if (loading) return ; + if (!state || !state.symbolQuotes) return null; + + return enumeratedSymbols.map((symbolName, i) => { + const symbolQuote = state.symbolQuotes[i]; + const symbol = symbolQuote.symbol; + const currencySymbol = getCurrencySymbol(symbolQuote.currency); + const marketPrice = Number(symbolQuote.regularMarketPrice).toFixed(2); + const marketChange = Number(symbolQuote.regularMarketChange).toFixed(2); + const marketPercentChange = Number( + symbolQuote.regularMarketChangePercent, + ).toFixed(2); + const stockUp = !marketChange.startsWith("-"); + + return ( + + + {showSymbol && symbol} + {(showCurrency || showMarketPrice) && showSymbol && ( +   + )} + {showCurrency && currencySymbol} + {showMarketPrice && marketPrice} + + + {(showMarketChange || showMarketPercent) &&  } + {showMarketChange && formatPriceChange(marketChange)} + {showMarketPercent && + showMarketChange && + ` (${formatPriceChange(marketPercentChange)}%)`} + {showMarketPercent && + !showMarketChange && + `${formatPriceChange(marketPercentChange)}%`} + + + ); + }); +}); + +Widget.displayName = "Stock"; + +/** + * Handle click event to open stock details + * @param {Event} e - The event object + */ +function openStock(e) { + Utils.clickEffect(e); +} + +/** + * Get the currency symbol for a given currency code + * @param {string} currency - The currency code + * @returns {string} The currency symbol + */ +function getCurrencySymbol(currency) { + if (currency === "EUR") return "€"; + if (currency === "USD") return "$"; + if (currency === "GBP") return "£"; + if (currency === "CNY") return "¥"; + if (currency === "JPY") return "¥"; + return `${currency} `; +} + +/** + * Format the price change with a '+' for positive changes + * @param {string} priceChange - The price change + * @returns {string} The formatted price change + */ +function formatPriceChange(priceChange) { + // Add a '+' for positive changes + return (priceChange.startsWith("-") ? "" : "+") + priceChange; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/time.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/time.jsx new file mode 100755 index 0000000..6890ffb --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/time.jsx @@ -0,0 +1,144 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { timeStyles as styles } from "../../styles/components/data/time"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 1000; + +/** + * Time widget component. + * @returns {JSX.Element|null} The rendered widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, timeWidgetOptions } = settings; + const { timeWidget } = widgets; + const { + refreshFrequency, + hour12, + dayProgress, + showSeconds, + showOnDisplay, + showIcon, + } = timeWidgetOptions; + + // Determine if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && timeWidget; + + // Calculate the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + // Options for formatting the time string + const options = React.useMemo( + () => ({ + hour: "numeric", + minute: "numeric", + second: showSeconds ? "numeric" : undefined, + hour12, + }), + [hour12, showSeconds], + ); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches the current time and updates the widget state. + */ + const getTime = React.useCallback(() => { + if (!visible) return; + const time = new Date().toLocaleString("en-UK", options); + setState({ time }); + setLoading(false); + }, [visible, options]); + + // Use server socket to get time updates + useServerSocket("time", visible, getTime, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getTime, refresh); + + if (loading) return ; + if (!state) return null; + const { time } = state; + + // Calculate the progress of the current day + const [dayStart, dayEnd] = [new Date(), new Date()]; + dayStart.setHours(0, 0, 0); + dayEnd.setHours(0, 0, 0); + dayEnd.setDate(dayEnd.getDate() + 1); + const range = dayEnd - dayStart; + const diff = Math.max(0, dayEnd - new Date()); + const fillerWidth = (100 - (100 * diff) / range) / 100; + + /** + * Icon component for the time widget. + * @returns {JSX.Element} The rendered icon. + */ + const TimeIcon = () => { + return ; + }; + + return ( + + {time} + {dayProgress && ( +
+ )} + + ); +}); + +Widget.displayName = "Time"; + +/** + * Icon component for displaying the time as a clock. + * @param {Object} props - The component props. + * @param {string} props.time - The current time string. + * @returns {JSX.Element} The rendered icon. + */ +function Icon({ time }) { + const [hours, minutes] = time.split(":"); + + const hoursInDegree = ((parseInt(hours, 10) % 12) / 12) * 360; + const minutesInDegree = (parseInt(minutes, 10) / 60) * 360; + + return ( +
+
+
+
+ ); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/user-widgets.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/user-widgets.jsx new file mode 100755 index 0000000..efa9876 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/user-widgets.jsx @@ -0,0 +1,181 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Settings from "../../settings"; +import * as Utils from "../../utils"; + +const { React } = Uebersicht; + +export default React.memo(UserWidgets); + +/** + * UserWidgets component that renders a list of user-defined widgets. + * @returns {JSX.Element[]} Array of UserWidget components. + */ +function UserWidgets() { + const { settings } = useSimpleBarContext(); + const { userWidgetsList } = settings.userWidgets; + + // Get the keys of the userWidgetsList object + const keys = Object.keys(userWidgetsList); + + // Map over the keys and render a UserWidget for each key + return keys.map((key) => ( + + )); +} + +UserWidgets.displayName = "UserWidgets"; + +/** + * UserWidget component that renders an individual user-defined widget. + * @param {Object} props - The component props. + * @param {string} props.index - The index of the widget. + * @param {Object} props.widget - The widget configuration object. + * @returns {JSX.Element|null} The rendered UserWidget component or null if not visible. + */ +const UserWidget = React.memo(({ index, widget }) => { + const { displayIndex, settings } = useSimpleBarContext(); + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(true); + const [isWidgetActive, setIsWidgetActive] = React.useState(true); + const { + icon, + backgroundColor, + output, + onClickAction, + onRightClickAction, + onMiddleClickAction, + refreshFrequency, + active, + noIcon, + hideWhenNoOutput = true, + showOnDisplay = "", + } = widget; + + // Determine if the widget should be visible based on display settings and active status + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && active; + + /** + * Resets the widget state and loading status. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setIsWidgetActive(true); + }; + + /** + * Fetches the widget output and updates the state. + */ + const getUserWidget = React.useCallback(async () => { + if (!visible) return; + const widgetOutput = await Utils.cachedRun(output, refreshFrequency); + const cleanedOutput = Utils.cleanupOutput(widgetOutput); + + // Hide widget if script returns empty output and hideWhenNoOutput is enabled + if ( + hideWhenNoOutput && + (!cleanedOutput.length || cleanedOutput.trim() === "") + ) { + setLoading(false); + setIsWidgetActive(false); + setState(undefined); + return; + } + + setState(widgetOutput); + setIsWidgetActive(true); + setLoading(false); + }, [visible, output, hideWhenNoOutput, refreshFrequency]); + + // Use server socket to listen for widget updates + useServerSocket( + "user-widget", + visible, + getUserWidget, + resetWidget, + setLoading, + index, + ); + + // Refresh the widget at the specified frequency + useWidgetRefresh(visible, getUserWidget, refreshFrequency); + + // Hide widget if not visible or if script indicates it should be inactive (only when hideWhenNoOutput is enabled) + if (!visible || (hideWhenNoOutput && !isWidgetActive)) return null; + + const isCustomColor = !Settings.userWidgetColors.includes(backgroundColor); + + const property = settings.global.widgetsBackgroundColorAsForeground + ? "color" + : "backgroundColor"; + + const style = settings.global.noColorInData + ? undefined + : { + [property]: isCustomColor ? backgroundColor : `var(${backgroundColor})`, + }; + + if (loading) return ; + + const Icon = !noIcon ? Icons[icon] : null; + + const hasOnClickAction = onClickAction?.trim().length > 0; + const hasRightClickAction = onRightClickAction?.trim().length > 0; + const hasMiddleClickAction = onMiddleClickAction?.trim().length > 0; + + /** + * Handles the click event for the widget. + * @param {Event} e - The click event. + */ + const onClick = async (e) => { + Utils.clickEffect(e); + await Uebersicht.run(onClickAction); + getUserWidget(); + }; + + /** + * Handles the right-click event for the widget. + * @param {Event} e - The right-click event. + */ + const onRightClick = async (e) => { + Utils.clickEffect(e); + await Uebersicht.run(onRightClickAction); + getUserWidget(); + }; + + /** + * Handles the middle-click event for the widget. + * @param {Event} e - The middle-click event. + */ + const onMiddleClick = async (e) => { + Utils.clickEffect(e); + await Uebersicht.run(onMiddleClickAction); + getUserWidget(); + }; + + const onClickProps = { + onClick: hasOnClickAction ? onClick : undefined, + onRightClick: hasRightClickAction ? onRightClick : undefined, + onMiddleClick: hasMiddleClickAction ? onMiddleClick : undefined, + }; + + return ( + + {state} + + ); +}); + +UserWidget.displayName = "UserWidget"; diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/viscosity-vpn.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/viscosity-vpn.jsx new file mode 100755 index 0000000..340c59b --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/viscosity-vpn.jsx @@ -0,0 +1,144 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { viscosityVPNStyles as styles } from "../../styles/components/data/viscosity-vpn"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 8000; + +/** + * Viscosity VPN Widget component. + * @returns {JSX.Element|null} The Viscosity VPN widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, vpnWidgetOptions } = settings; + const { vpnWidget } = widgets; + const { + refreshFrequency, + vpnConnectionName, + vpnShowConnectionName, + showOnDisplay, + showIcon, + } = vpnWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && vpnWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const [isViscosityActive, setIsViscosityActive] = React.useState(false); + + /** + * Reset the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + setIsViscosityActive(false); + }; + + /** + * Fetch the current VPN status. + */ + const getVPN = React.useCallback(async () => { + if (!visible) return; + const isRunning = await Utils.cachedRun( + `pgrep -xq 'Viscosity' && echo "true" || echo "false"`, + refresh, + ); + if (Utils.cleanupOutput(isRunning) === "false") { + setLoading(false); + setIsViscosityActive(false); + return; + } + const status = await Utils.cachedRun( + `osascript -e "tell application \\"Viscosity\\" to return state of the first connection where name is equal to \\"${vpnConnectionName}\\"" 2>/dev/null`, + refresh, + ); + if (!status.length) return; + setState({ status: Utils.cleanupOutput(status) }); + setIsViscosityActive(true); + setLoading(false); + }, [visible, vpnConnectionName, refresh]); + + // Use server socket to listen for VPN status updates + useServerSocket("viscosity-vpn", visible, getVPN, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getVPN, refresh); + + if (loading) return ; + if (!state || !vpnConnectionName.length || !isViscosityActive) return null; + + const { status } = state; + const isConnected = status === "Connected"; + + const classes = Utils.classNames("viscosity-vpn", { + "viscosity-vpn--disconnected": !isConnected, + }); + + const Icon = isConnected ? Icons.VPN : Icons.VPNOff; + + /** + * Handle click event to toggle VPN connection. + * @param {Event} e - The click event. + */ + const clicked = (e) => { + Utils.clickEffect(e); + toggleVPN(isConnected, vpnConnectionName, pushMissive); + setTimeout(getVPN, refreshFrequency / 2); + }; + + return ( + + {vpnShowConnectionName ? vpnConnectionName : status} + + ); +}); + +Widget.displayName = "ViscosityVPN"; + +/** + * Toggle the VPN connection. + * @param {boolean} isConnected - Whether the VPN is currently connected. + * @param {string} vpnConnectionName - The name of the VPN connection. + * @param {function} pushMissive - Function to push notifications. + */ +function toggleVPN(isConnected, vpnConnectionName, pushMissive) { + if (isConnected) { + Uebersicht.run( + `osascript -e 'tell application "Viscosity" to disconnect "${vpnConnectionName}"'`, + ); + Utils.notification( + `Disabling Viscosity ${vpnConnectionName} network...`, + pushMissive, + ); + } else { + Uebersicht.run( + `osascript -e 'tell application "Viscosity" to connect "${vpnConnectionName}"'`, + ); + Utils.notification( + `Enabling Viscosity ${vpnConnectionName} network...`, + pushMissive, + ); + } +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/weather.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/weather.jsx new file mode 100755 index 0000000..946fda1 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/weather.jsx @@ -0,0 +1,214 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import * as Utils from "../../utils"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; + +export { weatherStyles as styles } from "../../styles/components/data/weather"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 1000 * 60 * 30; // Default refresh frequency set to 30 minutes + +/** + * Weather widget component + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, weatherWidgetOptions } = settings; + const { weatherWidget } = widgets; + const { + refreshFrequency, + customLocation, + unit, + hideLocation, + hideGradient, + showOnDisplay, + showIcon, + } = weatherWidgetOptions; + + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && weatherWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + const location = React.useRef( + visible && customLocation.length ? customLocation : undefined, + ); + + /** + * Resets the widget state and loading status + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches weather data from wttr.in + */ + const getWeather = React.useCallback(async () => { + if (!visible) return; + if (!location.current) { + const position = await Promise.race([getPosition(), Utils.timeout(5000)]); + if (!position) await getWeather(); + const { city, zip } = position?.address || {}; + location.current = zip || city; + if (!location.current) return setLoading(false); + } + try { + const result = await fetch( + `https://wttr.in/${location.current}?format=j1`, + ); + const data = await result.json(); + setState({ location: location.current, data }); + } catch { + // eslint-disable-next-line no-console + console.error("Error while fetching weather") + } + setLoading(false); + }, [visible, location]); + + useServerSocket("weather", visible, getWeather, resetWidget, setLoading); + useWidgetRefresh(visible, getWeather, refresh); + + if (loading) return ; + if (!state || !state.data.current_condition) return null; + + const { + temp_C: tempC, + temp_F: tempF, + weatherDesc, + } = state.data.current_condition[0]; + const temperature = unit === "C" ? tempC : tempF; + const wttrUnitParam = unit === "C" ? "?m" : "?u"; + + const description = weatherDesc[0].value; + + const { astronomy } = state.data.weather[0]; + const sunriseData = astronomy[0].sunrise.replace(" AM", "").split(":"); + const sunsetData = astronomy[0].sunset.replace(" PM", "").split(":"); + + const now = new Date(); + const nowIntervalStart = new Date(); + nowIntervalStart.setHours(nowIntervalStart.getHours() - 1); + const nowIntervalStop = new Date(); + nowIntervalStop.setHours(nowIntervalStop.getHours() + 1); + const sunriseTime = new Date(); + sunriseTime.setHours( + parseInt(sunriseData[0], 10), + parseInt(sunriseData[1], 10), + 0, + 0, + ); + const sunsetTime = new Date(); + sunsetTime.setHours( + parseInt(sunsetData[0], 10) + 12, + parseInt(sunsetData[1], 10), + 0, + 0, + ); + + const atNight = sunriseTime >= now || now >= sunsetTime; + + const Icon = getIcon(description, atNight); + const label = getLabel(state.location, temperature, unit, hideLocation); + + const sunRising = + sunriseTime >= nowIntervalStart && sunriseTime <= nowIntervalStop; + const sunSetting = + sunsetTime >= nowIntervalStart && sunsetTime <= nowIntervalStop; + + /** + * Handles right-click event to refresh weather data + * @param {Event} e - The event object + */ + const onRightClick = (e) => { + Utils.clickEffect(e); + setLoading(true); + getWeather(); + Utils.notification("Refreshing forecast from wttr.in...", pushMissive); + }; + + const classes = Utils.classNames("weather", { + "weather--sunrise": sunRising, + "weather--sunset": sunSetting, + }); + + return ( + openWeather(e, pushMissive)} + onRightClick={onRightClick} + disableSlider + > + {!hideGradient &&
} + {label} + + ); +}); + +Widget.displayName = "Weather"; + +/** + * Returns the appropriate weather icon based on the description and time of day + * @param {string} description - Weather description + * @param {boolean} atNight - Whether it is currently night time + * @returns {JSX.Element} - The weather icon component + */ +function getIcon(description, atNight) { + if (description.includes("fog") || description.includes("mist")) { + return Icons.Fog; + } + if (description.includes("storm")) return Icons.Storm; + if (description.includes("snow")) return Icons.Snow; + if (description.includes("rain")) return Icons.Rain; + if (description.includes("cloud")) return Icons.Cloud; + if (atNight) return Icons.Moon; + return Icons.Sun; +} + +/** + * Returns the label for the weather widget + * @param {string} location - The location name + * @param {string} temperature - The temperature value + * @param {string} unit - The temperature unit (C or F) + * @param {boolean} hideLocation - Whether to hide the location name + * @returns {string} - The label text + */ +function getLabel(location, temperature, unit, hideLocation) { + if (!location) return "Fetching..."; + if (hideLocation) return `${temperature}°${unit}`; + return `${location}, ${temperature}°${unit}`; +} + +/** + * Opens the weather forecast in a new tab + * @param {Event} e - The event object + * @param {Function} pushMissive - Function to push notifications + */ +function openWeather(e, pushMissive) { + Utils.clickEffect(e); + Utils.notification("Opening forecast from wttr.in...", pushMissive); +} + +/** + * Gets the current geographical position of the user + * @returns {Promise} - The position object + */ +async function getPosition() { + return new Promise((resolve) => + navigator.geolocation.getCurrentPosition(resolve), + ); +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/wifi.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/wifi.jsx new file mode 100755 index 0000000..c7bedcd --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/wifi.jsx @@ -0,0 +1,154 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { wifiStyles as styles } from "../../styles/components/data/wifi"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 20000; + +/** + * Wifi widget component. + * @returns {JSX.Element|null} The Wifi widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings, pushMissive } = useSimpleBarContext(); + const { widgets, networkWidgetOptions } = settings; + const { wifiWidget } = widgets; + const { + refreshFrequency, + hideWifiIfDisabled, + toggleWifiOnClick, + networkDevice, + hideNetworkName, + showOnDisplay, + showIcon, + } = networkWidgetOptions; + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && wifiWidget; + + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setLoading(false); + }; + + /** + * Fetches the wifi status and SSID. + */ + const getWifi = React.useCallback(async () => { + if (!visible) return; + const [status, ssid] = await Promise.all([ + Utils.cachedRun( + `ifconfig ${networkDevice} | grep status | cut -c 10-`, + refresh, + ), + Utils.cachedRun( + `system_profiler SPAirPortDataType | awk '/Current Network/ {getline;$1=$1;print $0 | "tr -d ':'";exit}'`, + refresh, + ), + ]); + setState({ + status: Utils.cleanupOutput(status), + ssid: Utils.cleanupOutput(ssid), + }); + setLoading(false); + }, [networkDevice, visible, refresh]); + + useServerSocket("wifi", visible, getWifi, resetWidget, setLoading); + useWidgetRefresh(visible, getWifi, refresh); + + if (loading) return ; + if (!state) return null; + + const { status, ssid } = state; + const isActive = status === "active"; + const name = renderName(ssid, hideNetworkName); + + if (hideWifiIfDisabled && !isActive) return null; + + const classes = Utils.classNames("wifi", { + "wifi--hidden-name": !name, + "wifi--inactive": !isActive, + }); + + const Icon = isActive ? Icons.Wifi : Icons.WifiOff; + + /** + * Handles the click event to toggle wifi. + * @param {React.MouseEvent} e - The click event. + */ + const onClick = async (e) => { + Utils.clickEffect(e); + await toggleWifi(isActive, networkDevice, pushMissive); + getWifi(); + }; + + return ( + + {name} + + ); +}); + +Widget.displayName = "Wifi"; + +/** + * Toggles the wifi on or off. + * @param {boolean} isActive - Whether the wifi is currently active. + * @param {string} networkDevice - The network device name. + * @param {function} pushMissive - Function to push notifications. + */ +async function toggleWifi(isActive, networkDevice, pushMissive) { + if (isActive) { + await Uebersicht.run(`networksetup -setairportpower ${networkDevice} off`); + Utils.notification("Disabling network...", pushMissive); + } else { + await Uebersicht.run(`networksetup -setairportpower ${networkDevice} on`); + Utils.notification("Enabling network...", pushMissive); + } +} + +/** + * Opens the wifi preferences pane. + * @param {React.MouseEvent} e - The click event. + */ +function openWifiPreferences(e) { + Utils.clickEffect(e); + Uebersicht.run(`open /System/Library/PreferencePanes/Network.prefPane/`); +} + +/** + * Renders the wifi network name. + * @param {string} name - The network name. + * @param {boolean} hideNetworkName - Whether to hide the network name. + * @returns {string} The rendered network name. + */ +function renderName(name, hideNetworkName) { + if (!name || hideNetworkName) return ""; + if (name === "with an AirPort network.y off.") return "Disabled"; + if (name === "with an AirPort network.") return "Searching..."; + return name; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/youtube-music.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/youtube-music.jsx new file mode 100755 index 0000000..bf586b1 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/youtube-music.jsx @@ -0,0 +1,191 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Icons from "../icons/icons.jsx"; +import * as Utils from "../../utils"; + +export { youtubeMusicStyles as styles } from "../../styles/components/data/youtube-music"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 10000; + +/** + * YouTube Music Widget component. + * @returns {JSX.Element} The YouTube Music widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { + widgets: { youtubeMusicWidget }, + youtubeMusicWidgetOptions: { + refreshFrequency, + showSpecter, + showOnDisplay, + youtubeMusicPort, + showIcon, + }, + } = settings; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible on the current display + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && youtubeMusicWidget; + + const [state, setState] = React.useState(); + const [cachedAccessToken, setCachedAccessToken] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + const apiUrl = `http://localhost:${youtubeMusicPort}`; + + /** + * Resets the widget state. + */ + const resetWidget = () => { + setState(undefined); + setCachedAccessToken(undefined); + setLoading(false); + }; + + /** + * Fetches the access token for the YouTube Music API. + * @returns {Promise} The access token. + */ + const getAccessToken = React.useCallback(async () => { + // As of 2024/12/01, the generated access tokens don't expire + if (cachedAccessToken) return cachedAccessToken; + + const response = await fetch(`${apiUrl}/auth/simple-bar`, { + method: "POST", + }); + const json = await response.json(); + setCachedAccessToken(json.accessToken); + return json.accessToken; + }, [apiUrl, cachedAccessToken]); + + /** + * Fetches data from a specified route. + * @param {string} route - The API route to fetch data from. + * @param {string} [method="POST"] - The HTTP method to use. + * @returns {Promise} The fetch response. + */ + const fetchRoute = React.useCallback( + async (route, method = "POST") => { + const headers = {}; + const url = new URL(route, apiUrl); + + // All routes under /api are protected + const needsAuthentication = url.pathname.startsWith("/api"); + if (needsAuthentication) { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Failed to get access token"); + } + headers.Authorization = `Bearer ${accessToken}`; + } + + return await fetch(url, { method, headers }); + }, + [apiUrl, getAccessToken], + ); + + /** + * Refreshes the widget state by fetching the current song info. + */ + const refreshState = React.useCallback(async () => { + if (!visible) return; + + try { + const response = await fetchRoute("/api/v1/song-info", "GET"); + const json = await response.json(); + setState(json); + } catch { + // most likely due to offline server, reset state + resetWidget(); + } + }, [visible, fetchRoute]); + + // Use server socket to listen for updates + useServerSocket( + "youtube-music", + visible, + refreshState, + resetWidget, + setLoading, + ); + // Use widget refresh hook to periodically refresh the state + useWidgetRefresh(visible, refreshState, refresh); + + if (loading) return ; + + /** + * Handles click event to toggle play/pause. + * @param {React.MouseEvent} e - The click event. + */ + const onClick = async (e) => { + Utils.clickEffect(e); + await fetchRoute("/api/v1/toggle-play"); + await refreshState(); + }; + + /** + * Handles right-click event to skip to the next song. + * @param {React.MouseEvent} e - The right-click event. + */ + const onRightClick = async (e) => { + Utils.clickEffect(e); + await fetchRoute("/api/v1/next"); + await refreshState(); + }; + + /** + * Handles middle-click event to open YouTube Music app. + * @param {React.MouseEvent} e - The middle-click event. + */ + const onMiddleClick = async (e) => { + Utils.clickEffect(e); + Uebersicht.run(`open -a 'YouTube Music'`); + await refreshState(); + }; + + const classes = Utils.classNames("youtube-music", {}); + if (!state) return null; + const { title, artist, isPaused } = state; + if (!title) return null; + + const label = artist.length ? `${title} - ${artist}` : title; + const Icon = getIcon(isPaused); + + return ( + + {label} + + ); +}); + +Widget.displayName = "YouTube Music"; + +/** + * Returns the appropriate icon based on the play/pause state. + * @param {boolean} isPaused - Whether the music is paused. + * @returns {JSX.Element} The icon component. + */ +function getIcon(isPaused) { + return isPaused ? Icons.Paused : Icons.Playing; +} diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/zoom.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/zoom.jsx new file mode 100755 index 0000000..2c123e9 --- /dev/null +++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/zoom.jsx @@ -0,0 +1,107 @@ +import * as Uebersicht from "uebersicht"; +import * as DataWidget from "./data-widget.jsx"; +import * as DataWidgetLoader from "./data-widget-loader.jsx"; +import * as Icons from "../icons/icons.jsx"; +import { SuspenseIcon } from "../icons/icon.jsx"; +import useWidgetRefresh from "../../hooks/use-widget-refresh"; +import useServerSocket from "../../hooks/use-server-socket"; +import { useSimpleBarContext } from "../simple-bar-context.jsx"; +import * as Utils from "../../utils"; + +export { zoomStyles as styles } from "../../styles/components/data/zoom"; + +const { React } = Uebersicht; + +const DEFAULT_REFRESH_FREQUENCY = 5000; + +/** + * Zoom widget component. + * @returns {JSX.Element|null} The Zoom widget. + */ +export const Widget = React.memo(() => { + const { displayIndex, settings } = useSimpleBarContext(); + const { widgets, zoomWidgetOptions } = settings; + const { zoomWidget } = widgets; + const { refreshFrequency, showVideo, showMic, showOnDisplay } = + zoomWidgetOptions; + + // Determine the refresh frequency for the widget + const refresh = React.useMemo( + () => + Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), + [refreshFrequency], + ); + + // Determine if the widget should be visible + const visible = + Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && zoomWidget; + + const [state, setState] = React.useState(); + const [loading, setLoading] = React.useState(visible); + + /** + * Reset the widget state. + */ + const resetWidget = React.useCallback(() => { + setState(undefined); + setLoading(false); + }, []); + + /** + * Fetch the Zoom status for mic and video. + */ + const getZoom = React.useCallback(async () => { + if (!visible) return; + try { + const [mic, video] = await Promise.all([ + Utils.cachedRun( + `osascript ./simple-bar/lib/scripts/zoom-mute-status.applescript`, + refresh, + ), + Utils.cachedRun( + `osascript ./simple-bar/lib/scripts/zoom-video-status.applescript`, + refresh, + ), + ]); + setState({ + mic: Utils.cleanupOutput(mic), + video: Utils.cleanupOutput(video), + }); + setLoading(false); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching Zoom status:", error); + setLoading(false); + } + }, [visible, refresh]); + + // Use server socket to listen for Zoom events + useServerSocket("zoom", visible, getZoom, resetWidget, setLoading); + // Refresh the widget at the specified interval + useWidgetRefresh(visible, getZoom, refresh); + + if (loading) return ; + if (!state || (!state.mic.length && !state.video.length)) return null; + + const { mic, video } = state; + + const VideoIcon = video === "off" ? Icons.CameraOff : Icons.Camera; + const MicIcon = mic === "off" ? Icons.MicOff : Icons.MicOn; + + return ( + + {showVideo && ( + + + + )} + {showMic && ( + + + + )} + + ); +}); + +Widget.displayName = "Zoom"; -- cgit v1.3