diff --git a/README.md b/README.md index bca09ea..f67e956 100644 --- a/README.md +++ b/README.md @@ -30,27 +30,37 @@ curl -fsSL https://bun.sh/install | bash && \ sudo ln -s $HOME/.bun/bin/bun /usr/local/bin/bun ``` -Additional dependencies: +### Required ```sh pipewire + +## Resource monitoring modules libgtop + +## Bluetooth menu utilities bluez bluez-utils -grimblast -gpu-screen-recorder -hyprpicker -btop -networkmanager -matugen + +## Copy/Paste utilities wl-clipboard -swww + +## Compiler for sass/scss dart-sass + +## Brightness module for OSD brightnessctl + +## AGS requirements +networkmanager gnome-bluetooth-3.0 ``` -Optional Dependencies: +::: warning +HyprPanel will not run without the required dependencies. +::: + +### Optional ```sh ## Used for Tracking GPU Usage in your Dashboard (NVidia only) @@ -63,8 +73,26 @@ pywal ## To check for pacman updates in the default script used in the updates module pacman-contrib -## To switch between power profiles in battery module +## To switch between power profiles in the battery module power-profiles-daemon + +## To take snapshots with the default snapshot shortcut in the dashboard +grimblast + +## To record screen through the dashboard record shortcut +gpu-screen-recorder + +## To enable the eyedropper color picker with the default snapshot shortcut in the dashboard +hyprpicker + +## To click resource/stat bars in the dashboard and open btop +btop + +## To enable matugen based color theming +matugen + +## To enable matugen based color theming and setting wallpapers +swww ``` ### Arch diff --git a/customModules/config.ts b/customModules/config.ts index 36e2c4c..16d3f09 100644 --- a/customModules/config.ts +++ b/customModules/config.ts @@ -231,6 +231,12 @@ export const CustomModuleSettings = (): Scrollable => "Name of the network interface to poll.\nHINT: Get list of interfaces with 'cat /proc/net/dev'", type: 'string', }), + Option({ + opt: options.bar.customModules.netstat.dynamicIcon, + title: 'Use Network Icon', + subtitle: 'If enabled, shows the current network icon indicators instead of the static icon', + type: 'boolean', + }), Option({ opt: options.bar.customModules.netstat.icon, title: 'Netstat Icon', diff --git a/customModules/module.ts b/customModules/module.ts index 5438ade..39ade5f 100644 --- a/customModules/module.ts +++ b/customModules/module.ts @@ -1,6 +1,5 @@ import { BarBoxChild, Module } from 'lib/types/bar'; import { BarButtonStyles } from 'lib/types/options'; -import { Bind } from 'lib/types/variable'; import { GtkWidget } from 'lib/types/widget'; import options from 'options'; import Gtk from 'types/@girs/gtk-3.0/gtk-3.0'; @@ -12,6 +11,7 @@ const undefinedVar = Variable(undefined); export const module = ({ icon, textIcon, + useTextIcon = Variable(false).bind('value'), label, tooltipText, boxClass, @@ -21,19 +21,19 @@ export const module = ({ labelHook, hook, }: Module): BarBoxChild => { - const getIconWidget = (): GtkWidget | undefined => { + const getIconWidget = (useTxtIcn: boolean): GtkWidget | undefined => { let iconWidget: Gtk.Widget | undefined; - if (icon !== undefined) { + if (icon !== undefined && !useTxtIcn) { iconWidget = Widget.Icon({ class_name: `txt-icon bar-button-icon module-icon ${boxClass}`, icon: icon, - }) as unknown as Gtk.Widget; + }); } else if (textIcon !== undefined) { iconWidget = Widget.Label({ class_name: `txt-icon bar-button-icon module-icon ${boxClass}`, label: textIcon, - }) as unknown as Gtk.Widget; + }); } return iconWidget; @@ -55,25 +55,28 @@ export const module = ({ }, ), tooltip_text: tooltipText, - children: Utils.merge([showLabelBinding], (showLabelBinding): Gtk.Widget[] => { - const childrenArray: Gtk.Widget[] = []; - const iconWidget = getIconWidget(); + children: Utils.merge( + [showLabelBinding, useTextIcon], + (showLabel: boolean, forceTextIcon: boolean): Gtk.Widget[] => { + const childrenArray: Gtk.Widget[] = []; + const iconWidget = getIconWidget(forceTextIcon); - if (iconWidget !== undefined) { - childrenArray.push(iconWidget); - } + if (iconWidget !== undefined) { + childrenArray.push(iconWidget); + } - if (showLabelBinding) { - childrenArray.push( - Widget.Label({ - class_name: `bar-button-label module-label ${boxClass}`, - label: label, - setup: labelHook, - }) as unknown as Gtk.Widget, - ); - } - return childrenArray; - }) as Bind, + if (showLabel) { + childrenArray.push( + Widget.Label({ + class_name: `bar-button-label module-label ${boxClass}`, + label: label, + setup: labelHook, + }), + ); + } + return childrenArray; + }, + ), setup: hook, }), tooltip_text: tooltipText, diff --git a/customModules/netstat/index.ts b/customModules/netstat/index.ts index abfe2ca..1672db8 100644 --- a/customModules/netstat/index.ts +++ b/customModules/netstat/index.ts @@ -1,3 +1,4 @@ +const network = await Service.import('network'); import options from 'options'; import { module } from '../module'; import { inputHandler } from 'customModules/utils'; @@ -15,6 +16,7 @@ const { labelType, networkInterface, rateUnit, + dynamicIcon, icon, round, leftClick, @@ -59,6 +61,13 @@ export const Netstat = (): BarBoxChild => { }; const netstatModule = module({ + useTextIcon: dynamicIcon.bind('value').as((useDynamicIcon) => !useDynamicIcon), + icon: Utils.merge([network.bind('primary'), network.bind('wifi'), network.bind('wired')], (pmry, wfi, wrd) => { + if (pmry === 'wired') { + return wrd.icon_name; + } + return wfi.icon_name; + }), textIcon: icon.bind('value'), label: Utils.merge( [networkUsage.bind('value'), labelType.bind('value')], diff --git a/globals/weather.ts b/globals/weather.ts index 3da7ba1..026ce60 100644 --- a/globals/weather.ts +++ b/globals/weather.ts @@ -4,12 +4,65 @@ import { DEFAULT_WEATHER } from 'lib/types/defaults/weather.js'; import GLib from 'gi://GLib?version=2.0'; import { weatherIcons } from 'modules/icons/weather.js'; +const { EXISTS, IS_REGULAR } = GLib.FileTest; + const { key, interval, location } = options.menus.clock.weather; export const globalWeatherVar = Variable(DEFAULT_WEATHER); let weatherIntervalInstance: null | number = null; +key.connect('changed', () => { + const fetchedKey = getWeatherKey(key.value); + weatherApiKey.value = fetchedKey; +}); + +/** + * Retrieves the weather API key from a file if it exists and is valid. + * + * @param apiKey - The path to the file containing the weather API key. + * @returns - The weather API key if found, otherwise the original apiKey. + */ +const getWeatherKey = (apiKey: string): string => { + const weatherKey = apiKey; + + if (GLib.file_test(weatherKey, EXISTS) && GLib.file_test(weatherKey, IS_REGULAR)) { + try { + const fileContentArray = GLib.file_get_contents(weatherKey)[1]; + const fileContent = new TextDecoder().decode(fileContentArray); + + if (!fileContent) { + console.error('File content is empty'); + return ''; + } + + const parsedContent = JSON.parse(fileContent); + + if (parsedContent.weather_api_key !== undefined) { + return parsedContent.weather_api_key; + } else { + console.error('weather_api_key is missing in the JSON content'); + return ''; + } + } catch (error) { + console.error(`Failed to read or parse weather key file: ${error}`); + return ''; + } + } + + return apiKey; +}; + +const fetchedApiKey = getWeatherKey(key.value); +const weatherApiKey = Variable(fetchedApiKey); + +/** + * Sets up a weather update interval function. + * + * @param weatherInterval - The interval in milliseconds at which to fetch weather updates. + * @param loc - The location for which to fetch weather data. + * @param weatherKey - The API key for accessing the weather service. + */ const weatherIntervalFn = (weatherInterval: number, loc: string, weatherKey: string): void => { if (weatherIntervalInstance !== null) { GLib.source_remove(weatherIntervalInstance); @@ -46,22 +99,38 @@ const weatherIntervalFn = (weatherInterval: number, loc: string, weatherKey: str }); }; -Utils.merge([key.bind('value'), interval.bind('value'), location.bind('value')], (weatherKey, weatherInterval, loc) => { - if (!weatherKey) { - return (globalWeatherVar.value = DEFAULT_WEATHER); - } - weatherIntervalFn(weatherInterval, loc, weatherKey); -}); +Utils.merge( + [weatherApiKey.bind('value'), interval.bind('value'), location.bind('value')], + (weatherKey, weatherInterval, loc) => { + if (!weatherKey) { + return (globalWeatherVar.value = DEFAULT_WEATHER); + } + weatherIntervalFn(weatherInterval, loc, weatherKey); + }, +); -export const getTemperature = (wthr: Weather, unt: UnitType): string => { - if (unt === 'imperial') { - return `${Math.ceil(wthr.current.temp_f)}° F`; +/** + * Gets the temperature from the weather data in the specified unit. + * + * @param weatherData - The weather data object. + * @param unitType - The unit type, either 'imperial' or 'metric'. + * @returns - The temperature formatted as a string with the appropriate unit. + */ +export const getTemperature = (weatherData: Weather, unitType: UnitType): string => { + if (unitType === 'imperial') { + return `${Math.ceil(weatherData.current.temp_f)}° F`; } else { - return `${Math.ceil(wthr.current.temp_c)}° C`; + return `${Math.ceil(weatherData.current.temp_c)}° C`; } }; -export const getWeatherIcon = (fahren: number): Record => { +/** + * Returns the appropriate weather icon and color class based on the temperature in Fahrenheit. + * + * @param fahrenheit - The temperature in Fahrenheit. + * @returns - An object containing the weather icon and color class. + */ +export const getWeatherIcon = (fahrenheit: number): Record => { const icons = { 100: '', 75: '', @@ -80,7 +149,7 @@ export const getWeatherIcon = (fahren: number): Record => { type IconKeys = keyof typeof icons; const threshold: IconKeys = - fahren < 0 ? 0 : ([100, 75, 50, 25, 0] as IconKeys[]).find((threshold) => threshold <= fahren) || 0; + fahrenheit < 0 ? 0 : ([100, 75, 50, 25, 0] as IconKeys[]).find((threshold) => threshold <= fahrenheit) || 0; const icon = icons[threshold || 50]; const color = colors[threshold || 50]; @@ -91,23 +160,50 @@ export const getWeatherIcon = (fahren: number): Record => { }; }; -export const getWindConditions = (wthr: Weather, unt: UnitType): string => { - if (unt === 'imperial') { - return `${Math.floor(wthr.current.wind_mph)} mph`; +/** + * Gets the wind conditions from the weather data in the specified unit. + * + * @param weatherData - The weather data object. + * @param unitType - The unit type, either 'imperial' or 'metric'. + * @returns - The wind conditions formatted as a string with the appropriate unit. + */ +export const getWindConditions = (weatherData: Weather, unitType: UnitType): string => { + if (unitType === 'imperial') { + return `${Math.floor(weatherData.current.wind_mph)} mph`; } - return `${Math.floor(wthr.current.wind_kph)} kph`; + return `${Math.floor(weatherData.current.wind_kph)} kph`; }; -export const getRainChance = (wthr: Weather): string => `${wthr.forecast.forecastday[0].day.daily_chance_of_rain}%`; +/** + * Gets the chance of rain from the weather forecast data. + * + * @param weatherData - The weather data object. + * @returns - The chance of rain formatted as a percentage string. + */ +export const getRainChance = (weatherData: Weather): string => + `${weatherData.forecast.forecastday[0].day.daily_chance_of_rain}%`; +/** + * Type Guard + * Checks if the given title is a valid weather icon title. + * + * @param title - The weather icon title to check. + * @returns - True if the title is a valid weather icon title, false otherwise. + */ export const isValidWeatherIconTitle = (title: string): title is WeatherIconTitle => { return title in weatherIcons; }; -export const getWeatherStatusTextIcon = (wthr: Weather): WeatherIcon => { - let iconQuery = wthr.current.condition.text.trim().toLowerCase().replaceAll(' ', '_'); +/** + * Gets the appropriate weather icon based on the weather status text. + * + * @param weatherData - The weather data object. + * @returns - The weather icon corresponding to the weather status text. + */ +export const getWeatherStatusTextIcon = (weatherData: Weather): WeatherIcon => { + let iconQuery = weatherData.current.condition.text.trim().toLowerCase().replaceAll(' ', '_'); - if (!wthr.current.is_day && iconQuery === 'partly_cloudy') { + if (!weatherData.current.is_day && iconQuery === 'partly_cloudy') { iconQuery = 'partly_cloudy_night'; } diff --git a/lib/types/bar.d.ts b/lib/types/bar.d.ts index 99066d8..3886964 100644 --- a/lib/types/bar.d.ts +++ b/lib/types/bar.d.ts @@ -22,6 +22,7 @@ export type LabelHook = (self: Label) => void; export type Module = { icon?: string | Binding; textIcon?: string | Binding; + useTextIcon?: Binding; label?: string | Binding; labelHook?: LabelHook; boundLabel?: string; @@ -38,5 +39,3 @@ export type ResourceLabelType = 'used/total' | 'used' | 'percentage' | 'free'; export type NetstatLabelType = 'full' | 'in' | 'out'; export type RateUnit = 'GiB' | 'MiB' | 'KiB' | 'auto'; - - diff --git a/modules/menus/media/components/bar.ts b/modules/menus/media/components/bar.ts deleted file mode 100644 index 81448db..0000000 --- a/modules/menus/media/components/bar.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BoxWidget } from 'lib/types/widget'; -import { Mpris, MprisPlayer } from 'types/service/mpris'; - -const media = await Service.import('mpris'); - -const Bar = (getPlayerInfo: (media: Mpris) => MprisPlayer): BoxWidget => { - return Widget.Box({ - class_name: 'media-indicator-current-progress-bar', - hexpand: true, - children: [ - Widget.Box({ - hexpand: true, - child: Widget.Slider({ - hexpand: true, - tooltip_text: '--', - class_name: 'menu-slider media progress', - draw_value: false, - on_change: ({ value }) => { - const foundPlayer = getPlayerInfo(media); - if (foundPlayer === undefined) { - return; - } - return (foundPlayer.position = value * foundPlayer.length); - }, - setup: (self) => { - const update = (): void => { - const foundPlayer = getPlayerInfo(media); - if (foundPlayer !== undefined) { - const value = foundPlayer.length ? foundPlayer.position / foundPlayer.length : 0; - self.value = value > 0 ? value : 0; - } else { - self.value = 0; - } - }; - self.hook(media, update); - self.poll(1000, update); - - const updateTooltip = (): void => { - const foundPlayer = getPlayerInfo(media); - if (foundPlayer === undefined) { - self.tooltip_text = '00:00'; - return; - } - const curHour = Math.floor(foundPlayer.position / 3600); - const curMin = Math.floor((foundPlayer.position % 3600) / 60); - const curSec = Math.floor(foundPlayer.position % 60); - - if (typeof foundPlayer.position === 'number' && foundPlayer.position >= 0) { - const formatTime = (time: number): string => { - return time.toString().padStart(2, '0'); - }; - - const formatHour = (hour: number): string => { - return hour > 0 ? formatTime(hour) + ':' : ''; - }; - - self.tooltip_text = `${formatHour(curHour)}${formatTime(curMin)}:${formatTime(curSec)}`; - } else { - self.tooltip_text = `00:00`; - } - }; - self.poll(1000, updateTooltip); - self.hook(media, updateTooltip); - }, - }), - }), - ], - }); -}; - -export { Bar }; diff --git a/modules/menus/media/components/controls.ts b/modules/menus/media/components/controls.ts deleted file mode 100644 index 5cbce21..0000000 --- a/modules/menus/media/components/controls.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { MprisPlayer } from 'types/service/mpris.js'; -import icons from '../../../icons/index.js'; -import { LoopStatus, PlaybackStatus } from 'lib/types/mpris.js'; -import { BoxWidget } from 'lib/types/widget.js'; -const media = await Service.import('mpris'); - -const Controls = (getPlayerInfo: () => MprisPlayer): BoxWidget => { - const isValidLoopStatus = (status: string): status is LoopStatus => ['none', 'track', 'playlist'].includes(status); - - const isValidPlaybackStatus = (status: string): status is PlaybackStatus => - ['playing', 'paused', 'stopped'].includes(status); - - const isLoopActive = (player: MprisPlayer): string => { - return player['loop_status'] !== null && ['track', 'playlist'].includes(player['loop_status'].toLowerCase()) - ? 'active' - : ''; - }; - - const isShuffleActive = (player: MprisPlayer): string => { - return player['shuffle_status'] !== null && player['shuffle_status'] ? 'active' : ''; - }; - - return Widget.Box({ - class_name: 'media-indicator-current-player-controls', - vertical: true, - children: [ - Widget.Box({ - class_name: 'media-indicator-current-controls', - hpack: 'center', - children: [ - Widget.Box({ - class_name: 'media-indicator-control shuffle', - children: [ - Widget.Button({ - hpack: 'center', - hasTooltip: true, - setup: (self) => { - self.hook(media, () => { - const foundPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - self.tooltip_text = 'Unavailable'; - self.class_name = 'media-indicator-control-button shuffle disabled'; - return; - } - - self.tooltip_text = - foundPlayer.shuffle_status !== null - ? foundPlayer.shuffle_status - ? 'Shuffling' - : 'Not Shuffling' - : null; - self.on_primary_click = (): void => { - foundPlayer.shuffle(); - }; - self.class_name = `media-indicator-control-button shuffle ${isShuffleActive(foundPlayer)} ${foundPlayer.shuffle_status !== null ? 'enabled' : 'disabled'}`; - }); - }, - child: Widget.Icon(icons.mpris.shuffle['enabled']), - }), - ], - }), - Widget.Box({ - children: [ - Widget.Button({ - hpack: 'center', - child: Widget.Icon(icons.mpris.prev), - setup: (self) => { - self.hook(media, () => { - const foundPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - self.class_name = 'media-indicator-control-button prev disabled'; - return; - } - - self.on_primary_click = (): void => { - foundPlayer.previous(); - }; - self.class_name = `media-indicator-control-button prev ${foundPlayer.can_go_prev !== null && foundPlayer.can_go_prev ? 'enabled' : 'disabled'}`; - }); - }, - }), - ], - }), - Widget.Box({ - children: [ - Widget.Button({ - hpack: 'center', - setup: (self) => { - self.hook(media, () => { - const foundPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - self.class_name = 'media-indicator-control-button play disabled'; - return; - } - - self.on_primary_click = (): void => { - foundPlayer.playPause(); - }; - self.class_name = `media-indicator-control-button play ${foundPlayer.can_play !== null ? 'enabled' : 'disabled'}`; - }); - }, - child: Widget.Icon({ - icon: Utils.watch(icons.mpris.paused, media, 'changed', () => { - const foundPlayer: MprisPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - return icons.mpris['paused']; - } - const playbackStatus = foundPlayer.play_back_status?.toLowerCase(); - - if (playbackStatus && isValidPlaybackStatus(playbackStatus)) { - return icons.mpris[playbackStatus]; - } else { - return icons.mpris['paused']; - } - }), - }), - }), - ], - }), - Widget.Box({ - class_name: `media-indicator-control next`, - children: [ - Widget.Button({ - hpack: 'center', - child: Widget.Icon(icons.mpris.next), - setup: (self) => { - self.hook(media, () => { - const foundPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - self.class_name = 'media-indicator-control-button next disabled'; - return; - } - - self.on_primary_click = (): void => { - foundPlayer.next(); - }; - self.class_name = `media-indicator-control-button next ${foundPlayer.can_go_next !== null && foundPlayer.can_go_next ? 'enabled' : 'disabled'}`; - }); - }, - }), - ], - }), - Widget.Box({ - class_name: 'media-indicator-control loop', - children: [ - Widget.Button({ - hpack: 'center', - setup: (self) => { - self.hook(media, () => { - const foundPlayer = getPlayerInfo(); - if (foundPlayer === undefined) { - self.tooltip_text = 'Unavailable'; - self.class_name = 'media-indicator-control-button shuffle disabled'; - return; - } - - self.tooltip_text = - foundPlayer.loop_status !== null - ? foundPlayer.loop_status - ? 'Shuffling' - : 'Not Shuffling' - : null; - self.on_primary_click = (): void => { - foundPlayer.loop(); - }; - self.class_name = `media-indicator-control-button loop ${isLoopActive(foundPlayer)} ${foundPlayer.loop_status !== null ? 'enabled' : 'disabled'}`; - }); - }, - child: Widget.Icon({ - setup: (self) => { - self.hook(media, () => { - const foundPlayer: MprisPlayer = getPlayerInfo(); - - if (foundPlayer === undefined) { - self.icon = icons.mpris.loop['none']; - return; - } - - const loopStatus = foundPlayer.loop_status?.toLowerCase(); - - if (loopStatus && isValidLoopStatus(loopStatus)) { - self.icon = icons.mpris.loop[loopStatus]; - } else { - self.icon = icons.mpris.loop['none']; - } - }); - }, - }), - }), - ], - }), - ], - }), - ], - }); -}; - -export { Controls }; diff --git a/modules/menus/media/components/controls/index.ts b/modules/menus/media/components/controls/index.ts new file mode 100644 index 0000000..79dbf0e --- /dev/null +++ b/modules/menus/media/components/controls/index.ts @@ -0,0 +1,22 @@ +import { BoxWidget } from 'lib/types/widget.js'; +import { shuffleControl } from './shuffle/index.js'; +import { previousTrack } from './previous/index.js'; +import { playPause } from './playpause/index.js'; +import { nextTrack } from './next/index.js'; +import { loopControl } from './loop/index.js'; + +const Controls = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-player-controls', + vertical: true, + children: [ + Widget.Box({ + class_name: 'media-indicator-current-controls', + hpack: 'center', + children: [shuffleControl(), previousTrack(), playPause(), nextTrack(), loopControl()], + }), + ], + }); +}; + +export { Controls }; diff --git a/modules/menus/media/components/controls/loop/helpers.ts b/modules/menus/media/components/controls/loop/helpers.ts new file mode 100644 index 0000000..2a8d9a2 --- /dev/null +++ b/modules/menus/media/components/controls/loop/helpers.ts @@ -0,0 +1,11 @@ +import { LoopStatus } from 'lib/types/mpris'; +import { MprisPlayer } from 'types/service/mpris'; + +export const isValidLoopStatus = (status: string): status is LoopStatus => + ['none', 'track', 'playlist'].includes(status); + +export const isLoopActive = (player: MprisPlayer): string => { + return player['loop_status'] !== null && ['track', 'playlist'].includes(player['loop_status'].toLowerCase()) + ? 'active' + : ''; +}; diff --git a/modules/menus/media/components/controls/loop/index.ts b/modules/menus/media/components/controls/loop/index.ts new file mode 100644 index 0000000..77b347b --- /dev/null +++ b/modules/menus/media/components/controls/loop/index.ts @@ -0,0 +1,59 @@ +import icons from 'lib/icons'; +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; +import { MprisPlayer } from 'types/service/mpris'; +import { isLoopActive, isValidLoopStatus } from './helpers'; + +const media = await Service.import('mpris'); + +export const loopControl = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-control loop', + children: [ + Widget.Button({ + hpack: 'center', + setup: (self) => { + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + self.tooltip_text = 'Unavailable'; + self.class_name = 'media-indicator-control-button shuffle disabled'; + return; + } + + self.tooltip_text = foundPlayer.loop_status !== null ? foundPlayer.loop_status : 'None'; + + self.on_primary_click = (): void => { + foundPlayer.loop(); + }; + + const statusTag = foundPlayer.loop_status !== null ? 'enabled' : 'disabled'; + const isActiveTag = isLoopActive(foundPlayer); + + self.class_name = `media-indicator-control-button loop ${isActiveTag} ${statusTag}`; + }); + }, + child: Widget.Icon({ + setup: (self) => { + self.hook(media, () => { + const foundPlayer: MprisPlayer = getPlayerInfo(); + + if (foundPlayer === undefined) { + self.icon = icons.mpris.loop['none']; + return; + } + + const loopStatus = foundPlayer.loop_status?.toLowerCase(); + + if (loopStatus && isValidLoopStatus(loopStatus)) { + self.icon = icons.mpris.loop[loopStatus]; + } else { + self.icon = icons.mpris.loop['none']; + } + }); + }, + }), + }), + ], + }); +}; diff --git a/modules/menus/media/components/controls/next/index.ts b/modules/menus/media/components/controls/next/index.ts new file mode 100644 index 0000000..aae4c33 --- /dev/null +++ b/modules/menus/media/components/controls/next/index.ts @@ -0,0 +1,30 @@ +const media = await Service.import('mpris'); +import icons from 'lib/icons'; +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; + +export const nextTrack = (): BoxWidget => { + return Widget.Box({ + class_name: `media-indicator-control next`, + children: [ + Widget.Button({ + hpack: 'center', + child: Widget.Icon(icons.mpris.next), + setup: (self) => { + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + self.class_name = 'media-indicator-control-button next disabled'; + return; + } + + self.on_primary_click = (): void => { + foundPlayer.next(); + }; + self.class_name = `media-indicator-control-button next ${foundPlayer.can_go_next !== null && foundPlayer.can_go_next ? 'enabled' : 'disabled'}`; + }); + }, + }), + ], + }); +}; diff --git a/modules/menus/media/components/controls/playpause/helpers.ts b/modules/menus/media/components/controls/playpause/helpers.ts new file mode 100644 index 0000000..fa8b0d1 --- /dev/null +++ b/modules/menus/media/components/controls/playpause/helpers.ts @@ -0,0 +1,4 @@ +import { PlaybackStatus } from 'lib/types/mpris'; + +export const isValidPlaybackStatus = (status: string): status is PlaybackStatus => + ['playing', 'paused', 'stopped'].includes(status); diff --git a/modules/menus/media/components/controls/playpause/index.ts b/modules/menus/media/components/controls/playpause/index.ts new file mode 100644 index 0000000..8da3216 --- /dev/null +++ b/modules/menus/media/components/controls/playpause/index.ts @@ -0,0 +1,43 @@ +const media = await Service.import('mpris'); +import { getPlayerInfo } from '../../helpers'; +import { MprisPlayer } from 'types/service/mpris'; +import { isValidPlaybackStatus } from './helpers'; +import Button from 'types/widgets/button'; +import Icon from 'types/widgets/icon'; +import { Attribute } from 'lib/types/widget'; +import icons from 'modules/icons/index'; + +export const playPause = (): Button, Attribute> => { + return Widget.Button({ + hpack: 'center', + setup: (self) => { + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + self.class_name = 'media-indicator-control-button play disabled'; + return; + } + + self.on_primary_click = (): void => { + foundPlayer.playPause(); + }; + self.class_name = `media-indicator-control-button play ${foundPlayer.can_play !== null ? 'enabled' : 'disabled'}`; + }); + }, + child: Widget.Icon({ + icon: Utils.watch(icons.mpris.paused, media, 'changed', () => { + const foundPlayer: MprisPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + return icons.mpris['paused']; + } + const playbackStatus = foundPlayer.play_back_status?.toLowerCase(); + + if (playbackStatus && isValidPlaybackStatus(playbackStatus)) { + return icons.mpris[playbackStatus]; + } else { + return icons.mpris['paused']; + } + }), + }), + }); +}; diff --git a/modules/menus/media/components/controls/previous/index.ts b/modules/menus/media/components/controls/previous/index.ts new file mode 100644 index 0000000..b5094ed --- /dev/null +++ b/modules/menus/media/components/controls/previous/index.ts @@ -0,0 +1,27 @@ +const media = await Service.import('mpris'); +import icons from 'lib/icons'; +import { Attribute } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; +import Button from 'types/widgets/button'; +import Icon from 'types/widgets/icon'; + +export const previousTrack = (): Button, Attribute> => { + return Widget.Button({ + hpack: 'center', + child: Widget.Icon(icons.mpris.prev), + setup: (self) => { + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + self.class_name = 'media-indicator-control-button prev disabled'; + return; + } + + self.on_primary_click = (): void => { + foundPlayer.previous(); + }; + self.class_name = `media-indicator-control-button prev ${foundPlayer.can_go_prev !== null && foundPlayer.can_go_prev ? 'enabled' : 'disabled'}`; + }); + }, + }); +}; diff --git a/modules/menus/media/components/controls/shuffle/helpers.ts b/modules/menus/media/components/controls/shuffle/helpers.ts new file mode 100644 index 0000000..9286dc0 --- /dev/null +++ b/modules/menus/media/components/controls/shuffle/helpers.ts @@ -0,0 +1,5 @@ +import { MprisPlayer } from 'types/service/mpris'; + +export const isShuffleActive = (player: MprisPlayer): string => { + return player['shuffle_status'] !== null && player['shuffle_status'] ? 'active' : ''; +}; diff --git a/modules/menus/media/components/controls/shuffle/index.ts b/modules/menus/media/components/controls/shuffle/index.ts new file mode 100644 index 0000000..50fb641 --- /dev/null +++ b/modules/menus/media/components/controls/shuffle/index.ts @@ -0,0 +1,40 @@ +const media = await Service.import('mpris'); +import { Attribute, Child } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; +import Box from 'types/widgets/box'; +import { isShuffleActive } from './helpers'; +import icons from 'lib/icons'; + +export const shuffleControl = (): Box => { + return Widget.Box({ + class_name: 'media-indicator-control shuffle', + children: [ + Widget.Button({ + hpack: 'center', + hasTooltip: true, + setup: (self) => { + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + self.tooltip_text = 'Unavailable'; + self.class_name = 'media-indicator-control-button shuffle disabled'; + return; + } + + self.tooltip_text = + foundPlayer.shuffle_status !== null + ? foundPlayer.shuffle_status + ? 'Shuffling' + : 'Not Shuffling' + : null; + self.on_primary_click = (): void => { + foundPlayer.shuffle(); + }; + self.class_name = `media-indicator-control-button shuffle ${isShuffleActive(foundPlayer)} ${foundPlayer.shuffle_status !== null ? 'enabled' : 'disabled'}`; + }); + }, + child: Widget.Icon(icons.mpris.shuffle['enabled']), + }), + ], + }); +}; diff --git a/modules/menus/media/components/helpers.ts b/modules/menus/media/components/helpers.ts new file mode 100644 index 0000000..a0c1a26 --- /dev/null +++ b/modules/menus/media/components/helpers.ts @@ -0,0 +1,51 @@ +const media = await Service.import('mpris'); +import options from 'options.js'; +import { MprisPlayer } from 'types/service/mpris'; +const { tint, color } = options.theme.bar.menus.menu.media.card; + +const curPlayer = Variable(''); + +export const generateAlbumArt = (imageUrl: string): string => { + const userTint = tint.value; + const userHexColor = color.value; + + const r = parseInt(userHexColor.slice(1, 3), 16); + const g = parseInt(userHexColor.slice(3, 5), 16); + const b = parseInt(userHexColor.slice(5, 7), 16); + + const alpha = userTint / 100; + + const css = `background-image: linear-gradient( + rgba(${r}, ${g}, ${b}, ${alpha}), + rgba(${r}, ${g}, ${b}, ${alpha}), + ${userHexColor} 65em + ), url("${imageUrl}");`; + + return css; +}; + +export const initializeActivePlayerHook = (): void => { + media.connect('changed', () => { + const statusOrder = { + Playing: 1, + Paused: 2, + Stopped: 3, + }; + + const isPlaying = media.players.find((p) => p['play_back_status'] === 'Playing'); + + const playerStillExists = media.players.some((p) => curPlayer.value === p['bus_name']); + + const nextPlayerUp = media.players.sort( + (a, b) => statusOrder[a['play_back_status']] - statusOrder[b['play_back_status']], + )[0].bus_name; + + if (isPlaying || !playerStillExists) { + curPlayer.value = nextPlayerUp; + } + }); +}; + +export const getPlayerInfo = (): MprisPlayer => { + return media.players.find((p) => p.bus_name === curPlayer.value) || media.players[0]; +}; diff --git a/modules/menus/media/media.ts b/modules/menus/media/components/index.ts similarity index 51% rename from modules/menus/media/media.ts rename to modules/menus/media/components/index.ts index 2035516..07d4cfb 100644 --- a/modules/menus/media/media.ts +++ b/modules/menus/media/components/index.ts @@ -1,58 +1,16 @@ const media = await Service.import('mpris'); -import { MediaInfo } from './components/mediainfo.js'; -import { Controls } from './components/controls.js'; -import { Bar } from './components/bar.js'; -import { MprisPlayer } from 'types/service/mpris.js'; +import { MediaInfo } from './title/index.js'; +import { Controls } from './controls/index.js'; +import { Bar } from './timebar/index.js'; import options from 'options.js'; import { BoxWidget } from 'lib/types/widget.js'; +import { generateAlbumArt, getPlayerInfo, initializeActivePlayerHook } from './helpers.js'; const { tint, color } = options.theme.bar.menus.menu.media.card; -const generateAlbumArt = (imageUrl: string): string => { - const userTint = tint.value; - const userHexColor = color.value; +initializeActivePlayerHook(); - const r = parseInt(userHexColor.slice(1, 3), 16); - const g = parseInt(userHexColor.slice(3, 5), 16); - const b = parseInt(userHexColor.slice(5, 7), 16); - - const alpha = userTint / 100; - - const css = `background-image: linear-gradient( - rgba(${r}, ${g}, ${b}, ${alpha}), - rgba(${r}, ${g}, ${b}, ${alpha}), - ${userHexColor} 65em - ), url("${imageUrl}");`; - - return css; -}; const Media = (): BoxWidget => { - const curPlayer = Variable(''); - - media.connect('changed', () => { - const statusOrder = { - Playing: 1, - Paused: 2, - Stopped: 3, - }; - - const isPlaying = media.players.find((p) => p['play_back_status'] === 'Playing'); - - const playerStillExists = media.players.some((p) => curPlayer.value === p['bus_name']); - - const nextPlayerUp = media.players.sort( - (a, b) => statusOrder[a['play_back_status']] - statusOrder[b['play_back_status']], - )[0].bus_name; - - if (isPlaying || !playerStillExists) { - curPlayer.value = nextPlayerUp; - } - }); - - const getPlayerInfo = (): MprisPlayer => { - return media.players.find((p) => p.bus_name === curPlayer.value) || media.players[0]; - }; - return Widget.Box({ class_name: 'menu-section-container', children: [ @@ -69,13 +27,14 @@ const Media = (): BoxWidget => { hpack: 'fill', hexpand: true, vertical: true, - children: [MediaInfo(getPlayerInfo), Controls(getPlayerInfo), Bar(getPlayerInfo)], + children: [MediaInfo(), Controls(), Bar()], }), }), ], setup: (self) => { self.hook(media, () => { const curPlayer = getPlayerInfo(); + if (curPlayer !== undefined) { self.css = generateAlbumArt(curPlayer.track_cover_url); } diff --git a/modules/menus/media/components/mediainfo.ts b/modules/menus/media/components/mediainfo.ts deleted file mode 100644 index ea6cb76..0000000 --- a/modules/menus/media/components/mediainfo.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { BoxWidget } from 'lib/types/widget'; -import { MprisPlayer } from 'types/service/mpris'; - -const media = await Service.import('mpris'); - -const MediaInfo = (getPlayerInfo: () => MprisPlayer): BoxWidget => { - return Widget.Box({ - class_name: 'media-indicator-current-media-info', - hpack: 'center', - hexpand: true, - vertical: true, - children: [ - Widget.Box({ - class_name: 'media-indicator-current-song-name', - hpack: 'center', - children: [ - Widget.Label({ - truncate: 'end', - max_width_chars: 31, - wrap: true, - class_name: 'media-indicator-current-song-name-label', - setup: (self) => { - self.hook(media, () => { - const curPlayer = getPlayerInfo(); - return (self.label = - curPlayer !== undefined && curPlayer['track_title'].length - ? curPlayer['track_title'] - : 'No Media Currently Playing'); - }); - }, - }), - ], - }), - Widget.Box({ - class_name: 'media-indicator-current-song-author', - hpack: 'center', - children: [ - Widget.Label({ - truncate: 'end', - wrap: true, - max_width_chars: 35, - class_name: 'media-indicator-current-song-author-label', - setup: (self) => { - self.hook(media, () => { - const curPlayer = getPlayerInfo(); - - const makeArtistList = (trackArtists: string[]): string => { - if (trackArtists.length === 1 && !trackArtists[0].length) { - return '-----'; - } - - return trackArtists.join(', '); - }; - - return (self.label = - curPlayer !== undefined && curPlayer['track_artists'].length - ? makeArtistList(curPlayer['track_artists']) - : '-----'); - }); - }, - }), - ], - }), - Widget.Box({ - class_name: 'media-indicator-current-song-album', - hpack: 'center', - children: [ - Widget.Label({ - truncate: 'end', - wrap: true, - max_width_chars: 40, - class_name: 'media-indicator-current-song-album-label', - setup: (self) => { - self.hook(media, () => { - const curPlayer = getPlayerInfo(); - return (self.label = - curPlayer !== undefined && curPlayer['track_album'].length - ? curPlayer['track_album'] - : '---'); - }); - }, - }), - ], - }), - ], - }); -}; - -export { MediaInfo }; diff --git a/modules/menus/media/components/timebar/helpers.ts b/modules/menus/media/components/timebar/helpers.ts new file mode 100644 index 0000000..0aeb6a3 --- /dev/null +++ b/modules/menus/media/components/timebar/helpers.ts @@ -0,0 +1,39 @@ +import { Attribute } from 'lib/types/widget'; +import { MprisPlayer } from 'types/service/mpris'; +import Slider from 'types/widgets/slider'; + +export const updateTooltip = (self: Slider, foundPlayer: MprisPlayer): void => { + if (foundPlayer === undefined) { + self.tooltip_text = '00:00'; + return; + } + + const playerPosition = foundPlayer.position; + + const curHour = Math.floor(playerPosition / 3600); + const curMin = Math.floor((playerPosition % 3600) / 60); + const curSec = Math.floor(playerPosition % 60); + + if (typeof foundPlayer.position === 'number' && foundPlayer.position >= 0) { + const formatTime = (time: number): string => { + return time.toString().padStart(2, '0'); + }; + + const formatHour = (hour: number): string => { + return hour > 0 ? formatTime(hour) + ':' : ''; + }; + + self.tooltip_text = `${formatHour(curHour)}${formatTime(curMin)}:${formatTime(curSec)}`; + } else { + self.tooltip_text = `00:00`; + } +}; + +export const update = (self: Slider, foundPlayer: MprisPlayer): void => { + if (foundPlayer !== undefined) { + const value = foundPlayer.length ? foundPlayer.position / foundPlayer.length : 0; + self.value = value > 0 ? value : 0; + } else { + self.value = 0; + } +}; diff --git a/modules/menus/media/components/timebar/index.ts b/modules/menus/media/components/timebar/index.ts new file mode 100644 index 0000000..06b73ee --- /dev/null +++ b/modules/menus/media/components/timebar/index.ts @@ -0,0 +1,47 @@ +const media = await Service.import('mpris'); +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../helpers'; +import { update, updateTooltip } from './helpers'; + +const Bar = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-progress-bar', + hexpand: true, + children: [ + Widget.Box({ + hexpand: true, + child: Widget.Slider({ + hexpand: true, + tooltip_text: '--', + class_name: 'menu-slider media progress', + draw_value: false, + on_change: ({ value }) => { + const foundPlayer = getPlayerInfo(); + if (foundPlayer === undefined) { + return; + } + return (foundPlayer.position = value * foundPlayer.length); + }, + setup: (self) => { + self.poll(1000, () => { + const foundPlayer = getPlayerInfo(); + + if (foundPlayer?.play_back_status === 'Playing') { + update(self, foundPlayer); + updateTooltip(self, foundPlayer); + } + }); + + self.hook(media, () => { + const foundPlayer = getPlayerInfo(); + update(self, foundPlayer); + updateTooltip(self, foundPlayer); + }); + }, + }), + }), + ], + }); +}; + +export { Bar }; diff --git a/modules/menus/media/components/title/album/index.ts b/modules/menus/media/components/title/album/index.ts new file mode 100644 index 0000000..29a2be8 --- /dev/null +++ b/modules/menus/media/components/title/album/index.ts @@ -0,0 +1,27 @@ +const media = await Service.import('mpris'); +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; + +export const songAlbum = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-song-album', + hpack: 'center', + children: [ + Widget.Label({ + truncate: 'end', + wrap: true, + max_width_chars: 40, + class_name: 'media-indicator-current-song-album-label', + setup: (self) => { + self.hook(media, () => { + const curPlayer = getPlayerInfo(); + return (self.label = + curPlayer !== undefined && curPlayer['track_album'].length + ? curPlayer['track_album'] + : '---'); + }); + }, + }), + ], + }); +}; diff --git a/modules/menus/media/components/title/author/index.ts b/modules/menus/media/components/title/author/index.ts new file mode 100644 index 0000000..3e3d5af --- /dev/null +++ b/modules/menus/media/components/title/author/index.ts @@ -0,0 +1,36 @@ +const media = await Service.import('mpris'); +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; + +export const songAuthor = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-song-author', + hpack: 'center', + children: [ + Widget.Label({ + truncate: 'end', + wrap: true, + max_width_chars: 35, + class_name: 'media-indicator-current-song-author-label', + setup: (self) => { + self.hook(media, () => { + const curPlayer = getPlayerInfo(); + + const makeArtistList = (trackArtists: string[]): string => { + if (trackArtists.length === 1 && !trackArtists[0].length) { + return '-----'; + } + + return trackArtists.join(', '); + }; + + return (self.label = + curPlayer !== undefined && curPlayer['track_artists'].length + ? makeArtistList(curPlayer['track_artists']) + : '-----'); + }); + }, + }), + ], + }); +}; diff --git a/modules/menus/media/components/title/index.ts b/modules/menus/media/components/title/index.ts new file mode 100644 index 0000000..c5019e6 --- /dev/null +++ b/modules/menus/media/components/title/index.ts @@ -0,0 +1,14 @@ +import { BoxWidget } from 'lib/types/widget'; +import { songName } from './name/index'; +import { songAuthor } from './author/index'; +import { songAlbum } from './album/index'; + +export const MediaInfo = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-media-info', + hpack: 'center', + hexpand: true, + vertical: true, + children: [songName(), songAuthor(), songAlbum()], + }); +}; diff --git a/modules/menus/media/components/title/name/index.ts b/modules/menus/media/components/title/name/index.ts new file mode 100644 index 0000000..e6c7a6d --- /dev/null +++ b/modules/menus/media/components/title/name/index.ts @@ -0,0 +1,28 @@ +import { BoxWidget } from 'lib/types/widget'; +import { getPlayerInfo } from '../../helpers'; + +const media = await Service.import('mpris'); + +export const songName = (): BoxWidget => { + return Widget.Box({ + class_name: 'media-indicator-current-song-name', + hpack: 'center', + children: [ + Widget.Label({ + truncate: 'end', + max_width_chars: 31, + wrap: true, + class_name: 'media-indicator-current-song-name-label', + setup: (self) => { + self.hook(media, () => { + const curPlayer = getPlayerInfo(); + return (self.label = + curPlayer !== undefined && curPlayer['track_title'].length + ? curPlayer['track_title'] + : 'No Media Currently Playing'); + }); + }, + }), + ], + }); +}; diff --git a/modules/menus/media/index.ts b/modules/menus/media/index.ts index 489034d..24e4714 100644 --- a/modules/menus/media/index.ts +++ b/modules/menus/media/index.ts @@ -1,6 +1,6 @@ import Window from 'types/widgets/window.js'; import DropdownMenu from '../shared/dropdown/index.js'; -import { Media } from './media.js'; +import { Media } from './components/index.js'; import { Attribute, Child } from 'lib/types/widget.js'; import options from 'options.js'; diff --git a/options.ts b/options.ts index 6a72f82..4f57b41 100644 --- a/options.ts +++ b/options.ts @@ -980,6 +980,7 @@ const options = mkOptions(OPTIONS, { netstat: { label: opt(true), networkInterface: opt(''), + dynamicIcon: opt(false), icon: opt('󰖟'), round: opt(true), labelType: opt('full'), diff --git a/widget/settings/pages/config/menus/clock.ts b/widget/settings/pages/config/menus/clock.ts index 71d8e18..c6a81af 100644 --- a/widget/settings/pages/config/menus/clock.ts +++ b/widget/settings/pages/config/menus/clock.ts @@ -27,7 +27,7 @@ export const ClockMenuSettings = (): Scrollable => { Option({ opt: options.menus.clock.weather.key, title: 'Weather API Key', - subtitle: 'May require AGS restart. https://weatherapi.com/', + subtitle: "API Key or path to a JSON file that contains a 'weather_api_key' variable.", type: 'string', }), Option({