diff --git a/ags/app.ts b/ags/app.ts index 7df09e16..58d3e0e8 100644 --- a/ags/app.ts +++ b/ags/app.ts @@ -1,12 +1,14 @@ import AstalNotifd from "gi://AstalNotifd"; +import AstalHyprland from "gi://AstalHyprland"; +import AstalMpris from "gi://AstalMpris"; -import { App } from "astal/gtk3" +import { App, Astal } from "astal/gtk3" import { Wireplumber } from "./scripts/volume"; import { handleArguments } from "./scripts/arg-handler"; import { Time, timeout } from "astal/time"; -import { OSDModes, setOSDMode } from "./window/OSD"; +import { OSDModes, setOSDMode, variableHandler } from "./window/OSD"; import { Runner } from "./runner/Runner"; import { PluginApps } from "./runner/plugins/apps"; @@ -22,7 +24,11 @@ import { Stylesheet } from "./scripts/stylesheet"; import { Clipboard } from "./scripts/clipboard"; import { PluginClipboard } from "./runner/plugins/clipboard"; import { Config } from "./scripts/config"; +import { AstalPlayers } from "./scripts/player"; +const hyprland = AstalHyprland.get_default(); +const audio = Wireplumber.getDefault(); +const player = AstalPlayers.getDefault(); let osdTimer: (Time|undefined); let connections = new Map | number)>(); @@ -46,6 +52,7 @@ App.start({ main: (..._args: Array) => { console.log(`Initialized astal instance as: ${ App.instanceName || "astal" }`); + console.log("Config: initializing configuration file"); Config.getDefault(); @@ -61,11 +68,37 @@ App.start({ // Init clipboard module Clipboard.getDefault(); - connections.set(Wireplumber.getDefault(), [ - Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => - triggerOSD(OSDModes.SINK)) + //OSD Layout + connections.set(hyprland, [ + hyprland.connect("keyboard-layout", (_, Keyboard, layout) => { + variableHandler(OSDModes.LAYOUT, layout); + triggerOSD(OSDModes.LAYOUT); + }) + ]); + + const audioHandler = () => triggerOSD(OSDModes.SINK); + const sinkHandler = () => triggerOSD(OSDModes.SOURCE); + //OSD Wireplumber + connections.set(audio, [ + audio.getDefaultSink().connect("notify::volume", audioHandler), + audio.getDefaultSink().connect("notify::mute", audioHandler), + audio.getDefaultSource().connect("notify::volume", sinkHandler), + audio.getDefaultSource().connect("notify::mute", sinkHandler), ]); + //OSD Player + /*connections.set(player, [ + AstalPlayers.getDefault().activePlayer.connect("notify::title", () => + triggerOSD(OSDModes.PLAYER) + ), + player.activePlayer.connect("notify::playback-status", (s) => { + if (s.playbackStatus === AstalMpris.PlaybackStatus.PLAYING && + hyprland.get_focused_client().get_fullscreen() === AstalHyprland.Fullscreen.FULLSCREEN && + hyprland.get_focused_client().get_class().toLowerCase() !== player.activePlayer.get_enrty().toLowerCase()) + triggerOSD(OSDModes.PLAYER); + }) + ])*/ + connections.set(Notifications.getDefault(), [ Notifications.getDefault().connect("notification-added", (_, _notif: AstalNotifd.Notification) => { Windows.open("floating-notifications"); @@ -91,21 +124,15 @@ App.start({ }); function triggerOSD(osdModeParam: OSDModes) { - if(Windows.isVisible("control-center")) return; + if (Windows.isVisible("control-center")) return; Windows.open("osd"); + setOSDMode(osdModeParam); - if(!osdTimer) { - setOSDMode(osdModeParam); - osdTimer = timeout(3000, () => { - osdTimer = undefined; - Windows.close("osd"); - }); - - return; + if (osdTimer) { + osdTimer.cancel(); } - osdTimer.cancel(); osdTimer = timeout(3000, () => { Windows.close("osd"); osdTimer = undefined; diff --git a/ags/scripts/clipboard.ts b/ags/scripts/clipboard.ts index b4a98040..33a0550c 100644 --- a/ags/scripts/clipboard.ts +++ b/ags/scripts/clipboard.ts @@ -81,7 +81,8 @@ class Clipboard extends GObject.Object { } public async copyAsync(content: string): Promise { - await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => { + const sanitizedContent = content.replace(/"/g, '\\"'); + await execAsync(`wl-copy "${sanitizedContent}"`).catch((err: Gio.IOErrorEnum) => { console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message } | Stack:\n\t\t${err.stack}`); }); diff --git a/ags/scripts/player.ts b/ags/scripts/player.ts new file mode 100644 index 00000000..fb282a2e --- /dev/null +++ b/ags/scripts/player.ts @@ -0,0 +1,110 @@ +import { GObject, register, property } from "astal"; +import AstalMpris from "gi://AstalMpris"; + +export { AstalPlayers }; + +@register({ GTypeName: "AstalPlayers" }) +class AstalPlayers extends GObject.Object { + private static astalMpris: AstalMpris.Mpris = AstalMpris.Mpris.get_default(); + private static inst: AstalPlayers; + + #players: AstalMpris.Player[] = []; + #activePlayer: AstalMpris.Player | null = null; + #playerConnections: Map = new Map(); + + @property(AstalMpris.Player) + get activePlayer() { + return this.#activePlayer; + } + + constructor() { + super(); + + AstalPlayers.astalMpris.connect('player-added', (_, player) => this._addPlayer(player)); + AstalPlayers.astalMpris.connect('player-closed', (_, player) => this._removePlayer(player)); + + this.#players = AstalPlayers.astalMpris.get_players(); + this.#players.forEach(player => this._addPlayerSignals(player)); + + this._updateActivePlayer(); + } + + private _addPlayer(player: AstalMpris.Player) { + if (this.#players.includes(player)) return; + + this.#players.push(player); + this._addPlayerSignals(player); + this._updateActivePlayer(); + } + + private _addPlayerSignals(player: AstalMpris.Player) { + const handler = () => this._onPlayerStateChanged(player); + + const ids = [ + player.connect('notify::playback-status', handler), + //player.connect('notify::cover-art', handler), + player.connect('notify::identity', handler), + //player.connect('notify::track-id', handler), + // player.connect('notify::title', handler), + // player.connect('notify::artist', handler), + player.connect('notify::metadata', handler), + player.connect('notify::position', handler) + ]; + + this.#playerConnections.set(player, ids); + } + + private _removePlayer(player: AstalMpris.Player) { + this.#players = this.#players.filter(p => p !== player); + + if (this.#playerConnections.has(player)) { + const ids = this.#playerConnections.get(player)!; + ids.forEach(id => player.disconnect(id)); + this.#playerConnections.delete(player); + } + + this._updateActivePlayer(); + } + + private _onPlayerStateChanged(player: AstalMpris.Player) { + const wasActivePlayer = this.#activePlayer; + this._updateActivePlayer(); + + if (this.#activePlayer === wasActivePlayer && this.#activePlayer === player) { + this.notify('active-player'); + } + } + + private _updateActivePlayer() { + const playingPlayer = this.#players.find(p => p.playbackStatus === AstalMpris.PlaybackStatus.PLAYING); + let newActivePlayer; + + if (playingPlayer) { + newActivePlayer = playingPlayer; + } else if (this.#activePlayer && this.#players.includes(this.#activePlayer)) { + newActivePlayer = this.#activePlayer; + } else { + newActivePlayer = null; + } + + if (this.#activePlayer !== newActivePlayer) { + this.#activePlayer = newActivePlayer; + this.notify('active-player'); + } + } + + public static getDefault(): AstalPlayers { + if (!AstalPlayers.inst) { + AstalPlayers.inst = new AstalPlayers(); + } + return AstalPlayers.inst; + } + + public connect(signal: string, callback: (...args: any[]) => void): number { + return super.connect(signal, callback); + } + + public disconnect(id: number): void { + super.disconnect(id); + } +} diff --git a/ags/style.scss b/ags/style.scss index 1eab0543..5bed961e 100644 --- a/ags/style.scss +++ b/ags/style.scss @@ -222,7 +222,7 @@ menu { } } -.button-row { +.button-row, .top-row { & > button { background: colors.$bg-secondary; margin: 0 1px; @@ -232,7 +232,7 @@ menu { &:hover { background: colors.$bg-tertiary; } - + &:first-child { border-top-left-radius: 10px; border-bottom-left-radius: 10px; @@ -247,6 +247,24 @@ menu { } } +//Need some workouts/////////////// +switch { + padding: 4px; + border-radius: 16px; + background: functions.toRGB(color.adjust($color: wal.$color1, $lightness: -20%)); + &:checked { + background: colors.$bg-tertiary; + } +} + +switch slider { + border-radius: 12px; + color: colors.$bg-primary; + background: wal.$foreground; + transition: all 0.2s ease-in-out; +} +///////////////////////////////////// + selection { background: colors.$bg-tertiary; } diff --git a/ags/style/_control-center.scss b/ags/style/_control-center.scss index f454ebe8..88c865c2 100644 --- a/ags/style/_control-center.scss +++ b/ags/style/_control-center.scss @@ -35,7 +35,7 @@ font-weight: 600; } - & > box:not(.button-row) icon { + & > box:not(.button-row):not(.top-row) icon { font-size: 12px; color: colors.$fg-disabled; margin-right: 3px; @@ -47,7 +47,7 @@ color: colors.$fg-disabled; } - & .button-row { + & .button-row, & .top-row { & button { padding: 7px; margin: { @@ -169,6 +169,7 @@ } box.history { + margin-top: 10px; background: colors.$bg-translucent; box-shadow: 0 0 6px 1px colors.$bg-translucent; border-radius: 24px; @@ -186,8 +187,8 @@ box.history { } } - & > .button-row { - margin-top: 12px; + & > .button-row, & > .top-row { + margin-bottom: 12px; & button { padding: 6px; diff --git a/ags/style/_osd.scss b/ags/style/_osd.scss index 5740e113..939dd729 100644 --- a/ags/style/_osd.scss +++ b/ags/style/_osd.scss @@ -7,7 +7,12 @@ background: funs.toRGB(color.change($color: wal.$background, $alpha: 65%)); padding: 16px; border-radius: 24px; - min-width: 180px; + min-width: 60px; + + .action { + font-size: 14px; + font-weight: 600; + } .icon { margin-right: 10px; diff --git a/ags/widget/PopupWindow.ts b/ags/widget/PopupWindow.ts index fe66b05c..5f594e0f 100644 --- a/ags/widget/PopupWindow.ts +++ b/ags/widget/PopupWindow.ts @@ -1,6 +1,10 @@ -import { Binding } from "astal"; +import { Binding, bind } from "astal"; import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; import { BackgroundWindow } from "./BackgroundWindow"; +import AstalHyprland from "gi://AstalHyprland"; +import { Windows } from "../windows"; + +const hyprland = AstalHyprland.get_default(); type PopupWindowSpecificProps = { onDestroy?: (self: Widget.Window) => void; @@ -51,12 +55,23 @@ export function PopupWindow(props: PopupWindowProps): Widget.Window { winProps[key as keyof typeof winProps] = props[key as keyof typeof props]; } + let isFullscreen: boolean; + return new Widget.Window({ ...winProps, namespace: props?.namespace ?? "popup-window", className: `popup-window ${(props.namespace instanceof Binding ? props.namespace.get() : props.namespace) || ""}`, - keymode: Astal.Keymode.EXCLUSIVE, + keymode: bind(hyprland, 'focusedWorkspace').as(fw => { + + if (Windows.isVisible('logout-menu')) return Astal.Keymode.EXCLUSIVE; + + if (fw.get_last_client() === null) return Astal.Keymode.ON_DEMAND; + + return fw.get_last_client().get_fullscreen() === AstalHyprland.Fullscreen.FULLSCREEN && fw.get_last_client().get_workspace() === fw + ? Astal.Keymode.EXCLUSIVE + : Astal.Keymode.ON_DEMAND + }), anchor: TOP | LEFT | RIGHT | BOTTOM, exclusivity: props.exclusivity ?? Astal.Exclusivity.NORMAL, halign: undefined, diff --git a/ags/widget/assets/Slider.ts b/ags/widget/assets/Slider.ts new file mode 100644 index 00000000..b35cff42 --- /dev/null +++ b/ags/widget/assets/Slider.ts @@ -0,0 +1,406 @@ +import { timeout, GLib } from 'astal'; +import { Gtk, Gdk, Widget } from 'astal/gtk3'; +import AstalMpris from "gi://AstalMpris"; + +let pauseProgress = 1; // 0 = full wave, 1 = full straight + +export enum typeSliders { + MATERIAL_EXPRESSIVE_WAVE, //For players + MATERIAL_EXPRESSIVE_SLIDER +} + +function drawRoundedRectangleCustom(cr, x, y, width, height, options: { + topLeft?: boolean | number | 'small'; + topRight?: boolean | number | 'small'; + bottomRight?: boolean | number | 'small'; + bottomLeft?: boolean | number | 'small'; + minRadius?: number; +}) { + const { + topLeft = 0, + topRight = 0, + bottomRight = 0, + bottomLeft = 0, + minRadius = 2 + } = options; + + const calculateRadiusPercent = (height: number): number => { + const percent = -1.5 * height + 72.5; + return Math.max(30, Math.min(percent, 100)); + }; + + let tl, tr, br, bl; + + const dynamicMaxRadiusPercent = calculateRadiusPercent(height) / 100; + const maxRadius = height * dynamicMaxRadiusPercent; + + tl = topLeft === true ? maxRadius : (topLeft === 'small' ? minRadius : (topLeft || 0)); + tr = topRight === true ? maxRadius : (topRight === 'small' ? minRadius : (topRight || 0)); + br = bottomRight === true ? maxRadius : (bottomRight === 'small' ? minRadius : (bottomRight || 0)); + bl = bottomLeft === true ? maxRadius : (bottomLeft === 'small' ? minRadius : (bottomLeft || 0)); + + const maxR = Math.min(width / 2, height / 2); + tl = Math.min(tl, maxR); + tr = Math.min(tr, maxR); + br = Math.min(br, maxR); + bl = Math.min(bl, maxR); + + cr.moveTo(x + tl, y); + + // Top rigth + if (tr > 0) { + cr.arc(x + width - tr, y + tr, tr, 1.5 * Math.PI, 2 * Math.PI); + } else { + cr.lineTo(x + width, y); + } + + // Bottom rigth + if (br > 0) { + cr.arc(x + width - br, y + height - br, br, 0, 0.5 * Math.PI); + } else { + cr.lineTo(x + width, y + height); + } + + // Bottom left + if (bl > 0) { + cr.arc(x + bl, y + height - bl, bl, 0.5 * Math.PI, Math.PI); + } else { + cr.lineTo(x, y + height); + } + + // Top left + if (tl > 0) { + cr.arc(x + tl, y + tl, tl, Math.PI, 1.5 * Math.PI); + } else { + cr.lineTo(x, y); + cr.lineTo(x + tl, y); + } + + cr.closePath(); +} + +export function createSlider(model: { + // TO DO: + // ADD ICON IN SLIDER, + // ADD STEPS + getValue(): number; + getMaxValue(): number; + setValue(value: number): void; + getColor(): string | (() => string | null); + typeSlider(): typeSliders; + iconName?(): string | (() => string | null); + realtimeChangeValue?(): boolean; + getPlaybackStatus?(): AstalMpris.PlaybackStatus; + setSliderHeight?(): number; + extraSetup?(): () => { }; +}): Gtk.Widget { + let isDragging = false; + let dragProgress: number | null = null; + + const InRealTime = model.realtimeChangeValue ? model.realtimeChangeValue() : true; + + const isStreamPlaying = model.getMaxValue() >= GLib.MAXINT64 / 10000000; + + const iconSize = model.setSliderHeight ? model.setSliderHeight() - 4 : 11; + const iconNameRaw = model.iconName; + const iconName = typeof iconNameRaw === 'function' ? iconNameRaw() : iconNameRaw; + + const iconImage = iconName + ? new Gtk.Image({ + icon_name: iconName, + pixel_size: iconSize, + halign: Gtk.Align.START, + valign: Gtk.Align.CENTER, + margin_start: 14, + }) + : null; + + const drawingArea = new Widget.DrawingArea({ + className: "slider-drawing-area", + hexpand: true, + heightRequest: model.setSliderHeight + ? (model.setSliderHeight() < 15) ? model.setSliderHeight() * 2 : model.setSliderHeight() * 1.5 + : 25, + + setup: (self) => { + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.POINTER_MOTION_MASK + ); + + const updateDragPosition = (x: number) => { + const width = self.get_allocated_width(); + if (width === 0) return; + dragProgress = Math.max(0, Math.min(x / width, 1)); + }; + + // TO DO: + // ADD ANIMATION OF TRANSITION FROM END POSITION TO START POSITION, + // ADD CLASSIFICATION OF PRESSED BUTTON (PRIMARY & SECONDARY) + self.connect('button-press-event', (_, event) => { + if (isStreamPlaying) return; + isDragging = true; + const [, x] = event.get_coords(); + updateDragPosition(x); + }); + + self.connect('motion-notify-event', (_, event) => { + if (isDragging) { + const [, x] = event.get_coords(); + updateDragPosition(x); + + if (InRealTime && dragProgress !== null) model.setValue(dragProgress * model.getMaxValue()); + } + }); + + self.connect('button-release-event', () => { + if (isDragging && dragProgress !== null) { + const maxValue = model.getMaxValue(); + if (maxValue > 0) { + model.setValue(dragProgress * maxValue); + } + + isDragging = model.getPlaybackStatus ? timeout(40, () => { isDragging = false }) : false; + } + }); + + + let drawLoopId: number | null = null; + + self.connect('realize', () => { + if (drawLoopId === null) { + const fps = 60; + const FramesInMilliseconds = Math.round(1000 / fps); + drawLoopId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, FramesInMilliseconds, () => { + if (self.get_window()?.is_visible()) { + self.queue_draw(); + } + return GLib.SOURCE_CONTINUE; + }); + } + }); + + self.connect('draw', (self) => { + if (model.typeSlider() === typeSliders.MATERIAL_EXPRESSIVE_WAVE) { + const playbackStatus = model.getPlaybackStatus ? model.getPlaybackStatus() : null; + if (playbackStatus === AstalMpris.PlaybackStatus.PLAYING && !isDragging) { + if (pauseProgress > 0) { + pauseProgress = Math.max(0, pauseProgress - 0.03); + } + } else { + if (pauseProgress < 1) { + pauseProgress = Math.min(1, pauseProgress + 0.03); + } + } + } + }); + + self.connect("destroy", () => { + if (drawLoopId !== null) { + GLib.source_remove(drawLoopId); + drawLoopId = null; + } + }); + }, + + onDraw: (self, cr) => { + const styleContext = self.get_style_context(); + const width = self.get_allocated_width(); + const height = self.get_allocated_height(); + + const position = model.getValue(); + const length = model.getMaxValue(); + + const currentProgress = length > 0 ? position / length : 0; + const displayProgress = (isDragging && dragProgress !== null) + ? dragProgress + : (!isStreamPlaying + ? currentProgress + : 1); //1 - Max + + const styleType = model.typeSlider(); + const fg = styleContext.get_property('color', Gtk.StateFlags.NORMAL); + const rawColor = model.getColor; + const colorStr = typeof rawColor === 'function' ? rawColor() : rawColor; + const color = new Gdk.RGBA(); + if (colorStr) color.parse(colorStr); + + switch (styleType) { + case typeSliders.MATERIAL_EXPRESSIVE_SLIDER: { + const barHeight = model.setSliderHeight ? model.setSliderHeight() : 15; + const centerY = height / 2; + const progressX = width * displayProgress; + + const handleWidth = 5; + const handleHeight = Math.max(barHeight + handleWidth * 2, Math.min(height - barHeight, barHeight + barHeight / 2)); + const handleX = Math.max(0, Math.min(progressX - handleWidth / 2, width - handleWidth)); + const handleOffset = 5; + const activeEndX = handleX - handleOffset; + const inactiveStartX = handleX + handleWidth + handleOffset; + + const rawIconName = model.iconName; + const iconName = typeof rawIconName === 'function' ? rawIconName() : rawIconName; + const colorToUse = colorStr ? color : fg; + + // Mask + cr.save(); + drawRoundedRectangleCustom( + cr, + 0, centerY - barHeight / 2, + width, barHeight, + { topLeft: true, topRight: true, bottomRight: true, bottomLeft: true } + ); + cr.clip(); + + // Active line + if (displayProgress > 0 && activeEndX > 0) { + cr.setSourceRGBA(colorToUse.red, colorToUse.green, colorToUse.blue, colorToUse.alpha); + drawRoundedRectangleCustom( + cr, + 0, centerY - barHeight / 2, + Math.max(0, activeEndX), barHeight, + { topLeft: true, topRight: 'small', bottomLeft: true, bottomRight: 'small' } + ); + cr.fill(); + } + + // Unactive line + const hasInactive = displayProgress < 1 && inactiveStartX < width; + if (hasInactive) { + const inactiveWidth = width - inactiveStartX; + if (inactiveWidth > 0) { + cr.setSourceRGBA(fg.red, fg.green, fg.blue, 0.3); + drawRoundedRectangleCustom( + cr, + inactiveStartX, centerY - barHeight / 2, + inactiveWidth, barHeight, + { topLeft: 'small', topRight: true, bottomLeft: 'small', bottomRight: true } + ); + cr.fill(); + } + } + + // Icon + // Hm... + // if (iconName) { + // const iconSize = barHeight - 4; + // const iconPadding = 14; + // let pixbuf; + // try { + // pixbuf = Gtk.IconTheme.get_default().load_icon(iconName, iconSize, Gtk.IconLookupFlags.FORCE_SIZE); + // if (pixbuf) { + // Gdk.cairo_set_source_pixbuf(cr, pixbuf, iconPadding, centerY - iconSize / 2); + // cr.paint(); + // } + // } catch (e) { + // console.log(`Failed to load icon '${iconName}': ${e}`); + // } + // } + + // Max Value Dot + if (hasInactive) { + //const dotRadius = barHeight / 6; + const dotRadius = 2.5; + //const dotX = width - barHeight / 2; + const dotX = width - dotRadius * 2; + //const colorToUse = colorStr ? color : fg; + + cr.save(); + drawRoundedRectangleCustom( + cr, + inactiveStartX, centerY - barHeight / 2, + width - inactiveStartX, barHeight, + { topLeft: false, topRight: true, bottomRight: true, bottomLeft: false } + ); + cr.clip(); + + cr.setSourceRGBA(colorToUse.red, colorToUse.green, colorToUse.blue, colorToUse.alpha); + cr.copyPath(); + cr.arc(dotX, centerY, dotRadius, 0, 2 * Math.PI); + cr.fill(); + cr.restore(); + } + + // Drop the mask + cr.restore(); + + // Draw on top of all surfaces + cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha); + drawRoundedRectangleCustom( + cr, + handleX, centerY - handleHeight / 2, + handleWidth, handleHeight, + { topLeft: true, topRight: true, bottomRight: true, bottomLeft: true } + ); + cr.fill(); + + break; + } + case typeSliders.MATERIAL_EXPRESSIVE_WAVE: { + const lineThickness = 6; + + const baseWaveAmp = 3; + const waveAmp = baseWaveAmp * (1 - pauseProgress); + const waveFreq = 0.15; + const time = Date.now() * 0.003; + + const rectangleHandleWidth = 5; + const rectangleHandleHeight = 20; + const rectangleHandleX = Math.max(0, Math.min(width * displayProgress - (rectangleHandleWidth / 2), width - rectangleHandleWidth)); + + const centerY = height / 2; + const progressX = width * displayProgress; + const startX = lineThickness / 2; + const activeEndX = Math.max(startX, progressX - rectangleHandleWidth * 2); + + const radius = 10; + + cr.setLineWidth(lineThickness); + cr.setLineCap(1); // ROUND caps + + // Active line (wave or line) + if (activeEndX > startX) { + const src = colorStr ? color : fg; + cr.setSourceRGBA(src.red, src.green, src.blue, src.alpha); + + cr.newPath(); + cr.moveTo(startX, centerY + Math.sin(startX * waveFreq + time) * waveAmp); + for (let x = startX; x < activeEndX; x++) { + const y = centerY + Math.sin(x * waveFreq + time) * waveAmp; + cr.lineTo(x, y); + } + cr.stroke(); + } + + // Unactive line + const inactiveStartX = Math.max(lineThickness * 2, Math.min(width, progressX + rectangleHandleWidth * 2)); + if (inactiveStartX < width - startX) { + cr.setSourceRGBA(fg.red, fg.green, fg.blue, 0.3); + cr.newPath(); + cr.moveTo(inactiveStartX, centerY); + cr.lineTo(width - startX, centerY); + cr.stroke(); + } + + // Handle + if (!isStreamPlaying) { + cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha); + drawRoundedRectangleCustom( + cr, + rectangleHandleX, + centerY - rectangleHandleHeight / 2, + rectangleHandleWidth, + rectangleHandleHeight, + { topLeft: true, topRight: true, bottomRight: true, bottomLeft: true } + ); + cr.fill(); + } + break; + } + } + } + } as Widget.DrawingAreaProps) + + return drawingArea; +} diff --git a/ags/widget/bar/Media.ts b/ags/widget/bar/Media.ts index a2487cbc..a5115b4f 100644 --- a/ags/widget/bar/Media.ts +++ b/ags/widget/bar/Media.ts @@ -1,4 +1,4 @@ -import { bind, exec } from "astal"; +import { Variable, bind, exec } from "astal"; import { Gtk, Widget } from "astal/gtk3"; import AstalMpris from "gi://AstalMpris"; import { getSymbolicIcon } from "../../scripts/apps"; @@ -6,8 +6,11 @@ import { Separator, SeparatorProps } from "../Separator"; import { Windows } from "../../windows"; import { Clipboard } from "../../scripts/clipboard"; +import { AstalPlayers } from "../../scripts/player"; + export function Media(): Gtk.Widget { - const connections: Array = []; + + const players = AstalPlayers.getDefault(); const mediaControlsRevealer: Widget.Revealer = new Widget.Revealer({ transitionType: Gtk.RevealerTransitionType.SLIDE_RIGHT, @@ -17,125 +20,113 @@ export function Media(): Gtk.Widget { className: "media-controls button-row", expand: false, homogeneous: false, - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? [ - new Widget.Button({ - className: "link", - image: new Widget.Icon({ - icon: "edit-paste-symbolic" - } as Widget.IconProps), - tooltipText: "Copy link to Clipboard", - // AstalMpris.Player.metadata works only sometimes, so I'm not using it - visible: bind(players[0], "metadata").as(Boolean), - onClick: async () => { - const link = exec(`playerctl --player=${ - players[0].busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") - } metadata xesam:url`); - - link && Clipboard.getDefault().copyAsync(link); - } - } as Widget.ButtonProps), - new Widget.Button({ - className: "previous", - image: new Widget.Icon({ - icon: "media-skip-backward-symbolic" - } as Widget.IconProps), - tooltipText: "Previous", - onClick: () => players[0].canGoPrevious && players[0].previous() - } as Widget.ButtonProps), - new Widget.Button({ - className: "play-pause", - tooltipText: bind(players[0], "playback_status").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "Pause" - : "Play"), - image: new Widget.Icon({ - icon: bind(players[0], "playbackStatus").as((status: AstalMpris.PlaybackStatus) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic") - } as Widget.IconProps), - onClick: () => players[0].playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? - players[0].play() - : players[0].pause() - } as Widget.ButtonProps), - new Widget.Button({ - className: "next", - image: new Widget.Icon({ - icon: "media-skip-forward-symbolic" - } as Widget.IconProps), - tooltipText: "Next", - onClick: () => players[0].canGoNext && players[0].next() - } as Widget.ButtonProps) - ] : new Widget.Label({ - label: "Don't Stop The Music!" - } as Widget.LabelProps) - ) + children: [ + // new Widget.Button({ + // className: "link", + // image: new Widget.Icon({ + // icon: "edit-paste-symbolic" + // } as Widget.IconProps), + // tooltipText: "Copy link to Clipboard", + // // AstalMpris.Player.metadata works only sometimes, so I'm not using it + // visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p.metadata ? true : false), + // onClickRelease: async () => { + // const link = exec(`playerctl --player=${ + // players.activePlayer.busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") + // } metadata xesam:url`); + // link && Clipboard.getDefault().copyAsync(link); + // } + // } as Widget.ButtonProps), + new Widget.Button({ + className: "previous", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p.canGoPrevious ? true : false), + image: new Widget.Icon({ + icon: "media-skip-backward-symbolic" + } as Widget.IconProps), + tooltipText: "Previous", + onClickRelease: () => players.activePlayer.canGoPrevious && players.activePlayer.previous() + } as Widget.ButtonProps), + new Widget.Button({ + className: "play-pause", + tooltipText: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p?.playback_status === AstalMpris.PlaybackStatus.PLAYING + ? "Pause" : "Play"), + image: new Widget.Icon({ + icon: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p?.playbackStatus === AstalMpris.PlaybackStatus.PLAYING ? + "media-playback-pause-symbolic" + : "media-playback-start-symbolic") + } as Widget.IconProps), + onClickRelease: () => players.activePlayer.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? + players.activePlayer.play() + : players.activePlayer.pause() + } as Widget.ButtonProps), + new Widget.Button({ + className: "next", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p.canGoNext ? true : false), + image: new Widget.Icon({ + icon: "media-skip-forward-symbolic" + } as Widget.IconProps), + tooltipText: "Next", + onClickRelease: () => players.activePlayer.canGoNext && players.activePlayer.next() + } as Widget.ButtonProps) + ] } as Widget.BoxProps) } as Widget.RevealerProps); const mediaWidget = new Widget.EventBox({ className: "media-eventbox", - visible: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] && players[0].get_available()), - onDestroy: (_) => connections.map(id => _.disconnect(id)), + visible: bind(players, "activePlayer").as((activePlayer: AstalMpris.Player) => + activePlayer && activePlayer.get_available()), onClick: () => Windows.toggle("center-window"), child: new Widget.Box({ - className: "media", + className: "media", children: [ new Widget.Box({ spacing: 4, - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? [ + children: [ new Widget.Icon({ - icon: bind(players[0], "busName").as((busName: string) => { - const splitName = busName.split('.').filter(str => str !== "" && !str.toLowerCase().includes('instance')); - if (getSymbolicIcon(splitName[splitName.length - 1])) { - return getSymbolicIcon(splitName[splitName.length - 1]); - } else { - return "folder-music-symbolic" - }; - }) + icon: bind(players, "activePlayer").as((p: AstalMpris.Player) => getSymbolicIcon(p.get_entry()) ?? + getSymbolicIcon(p.get_bus_name().split('.').filter(str => !str.toLowerCase().includes('instance')).join('.')) ?? + "folder-music-symbolic") } as Widget.IconProps), new Widget.Label({ className: "title", - label: bind(players[0], "title").as((title: string) => title || "No Title"), + label: bind(players, "activePlayer").as((p: AstalMpris.Player) => p?.title ?? "No Title"), maxWidthChars: 20, truncate: true } as Widget.LabelProps), Separator({ + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p?.artist ? true : false), orientation: Gtk.Orientation.HORIZONTAL, size: 1, margin: 5, - //cssColor: `rgb(180, 180, 180)`, alpha: .3 } as SeparatorProps), new Widget.Label({ className: "artist", - label: bind(players[0], "artist").as((artist: string) => artist || "No Artist"), + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p?.artist ? true : false), + label: bind(players, "activePlayer").as((p: AstalMpris.Player) => p?.artist ?? "No Artist"), maxWidthChars: 18, truncate: true } as Widget.LabelProps) - ] : new Widget.Label({ - label: "Crazy to think this widget haven't disappeared yet!" - } as Widget.LabelProps) - ) + ] } as Widget.BoxProps), mediaControlsRevealer ] } as Widget.BoxProps) } as Widget.EventBoxProps); - connections.push( - mediaWidget.connect("hover", () => { + mediaWidget.hook(mediaWidget, 'hover', () => { + if (!Windows.isVisible("center-window")) { mediaControlsRevealer.set_reveal_child(true); mediaWidget.className = mediaWidget.className + " reveal"; - }), - mediaWidget.connect("hover-lost", (_) => { - mediaControlsRevealer.set_reveal_child(false); - _.className = mediaWidget.className.replaceAll(" reveal", ""); - }) - ); + } + }); + + mediaWidget.hook(mediaWidget, 'hover-lost', (_) => { + mediaControlsRevealer.set_reveal_child(false); + _.className = mediaWidget.className.replaceAll(" reveal", ""); + }); return mediaWidget; } diff --git a/ags/widget/bar/Status.ts b/ags/widget/bar/Status.ts index c0759810..58c4a799 100644 --- a/ags/widget/bar/Status.ts +++ b/ags/widget/bar/Status.ts @@ -2,7 +2,7 @@ import AstalBluetooth from "gi://AstalBluetooth"; import AstalNetwork from "gi://AstalNetwork"; import AstalWp from "gi://AstalWp"; -import { bind, Binding, Variable } from "astal"; +import { bind, Binding, Variable, GLib } from "astal"; import { Gtk, Widget } from "astal/gtk3"; import { Wireplumber } from "../../scripts/volume"; import { Notifications } from "../../scripts/notifications"; @@ -10,7 +10,9 @@ import { Windows } from "../../windows"; import { Recording } from "../../scripts/recording"; import { getDateTime } from "../../scripts/time"; import { tr } from "../../i18n/intl"; +import { Time, timeout, idle } from "astal/time"; +import { IconStatus } from "../../scripts/icons"; export function Status(): Gtk.Widget { const recordingTimer: Variable = Variable.derive([ @@ -88,24 +90,64 @@ export function Status(): Gtk.Widget { function volumeStatus(props: { className?: string, endpoint: AstalWp.Endpoint, icon?: (string|Binding) }): Gtk.Widget { return new Widget.EventBox({ className: props.className, - onScroll: (_, event) => - event.delta_y > 0 ? + onScroll: (_, event) => //ugh..... + event.delta_y > 0 ? Wireplumber.getDefault().decreaseEndpointVolume(props.endpoint, 5) : Wireplumber.getDefault().increaseEndpointVolume(props.endpoint, 5), - child: new Widget.Box({ - spacing: 2, - children: [ - new Widget.Icon({ - visible: props.icon, - icon: props.icon, - } as Widget.IconProps), - new Widget.Label({ - className: "volume", - label: bind(props.endpoint, "volume").as((volume: number) => - Math.floor(volume * 100) + "%") - } as Widget.LabelProps), - ] - } as Widget.BoxProps) + setup: (eventbox) => { + let timer: (Time|undefined); + const connections: Array = []; + + const showRevealer = (self: any) => { + self.revealChild = true; + + if (timer) { + timer.cancel(); + } + + timer = timeout(3000, () => { + self.revealChild = false + timer = undefined; + }); + } + + eventbox.add(new Widget.Box({ + spacing: 2, + children: [ + new Widget.Icon({ + visible: props.icon, + icon: props.icon, + }), + new Widget.Revealer({ + transitionDuration: 180, + transitionType: Gtk.RevealerTransitionType.SLIDE_LEFT, + setup: (self) => { + + connections.push( + eventbox.connect("hover", () => self.revealChild = true), + eventbox.connect("hover-lost", () => { + timeout(20, () => { //for smoothness (do not change it) + self.revealChild = false + }) + }), + Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => { + showRevealer(self) + }), + Wireplumber.getDefault().getDefaultSource().connect("notify::volume", () => { + showRevealer(self) + }), + ) + }, + onDestroy: () => connections.map(id => eventbox.disconnect(id)), + child: new Widget.Label({ + className: "volume", + label: bind(props.endpoint, "volume").as((volume: number) => + Math.floor(volume * 100) + "%") + } as Widget.LabelProps), + } as Widget.RevealerProps) + ] + } as Widget.BoxProps)) + } } as Widget.EventBoxProps) } diff --git a/ags/widget/bar/Workspaces.ts b/ags/widget/bar/Workspaces.ts index 217ed60d..6341dcbd 100644 --- a/ags/widget/bar/Workspaces.ts +++ b/ags/widget/bar/Workspaces.ts @@ -45,9 +45,9 @@ export function Workspaces(): Gtk.Widget { bind(AstalHyprland.get_default(), "focusedWorkspace") ], (lastClient, focusedWorkspace) => focusedWorkspace?.id === workspace.id ? false : Boolean(lastClient))(), - icon: bind(lastClient, "initialClass").as((initialClass) => + icon: lastClient ? bind(lastClient, "initialClass").as((initialClass) => getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ?? - "application-x-executable-symbolic") + "application-x-executable-symbolic") : undefined } as Widget.IconProps) ) } as Widget.BoxProps), diff --git a/ags/widget/center-window/BigMedia.ts b/ags/widget/center-window/BigMedia.ts index d283975c..e68238c8 100644 --- a/ags/widget/center-window/BigMedia.ts +++ b/ags/widget/center-window/BigMedia.ts @@ -1,227 +1,203 @@ -import { AstalIO, bind, Binding, exec, timeout } from "astal"; +import { AstalIO, bind, Binding, exec, timeout, GLib } from "astal"; import { Gtk, Widget } from "astal/gtk3"; import AstalMpris from "gi://AstalMpris"; -import { Clipboard } from "../../scripts/clipboard"; +import { AstalPlayers } from "../../scripts/player"; +import { createSlider, typeSliders } from "../assets/Slider"; +import { Wallpaper } from "../../scripts/wallpaper"; +import { ProgressBar } from "../assets/ProgressBar"; +function formatTime(seconds: number): string { + if (isNaN(seconds) || seconds < 0) return "0:00"; + const totalSeconds = Math.floor(seconds); + const sec = totalSeconds % 60; + const min = Math.floor((totalSeconds % 3600) / 60); + const hours = Math.floor(totalSeconds / 3600); + + const minStr = (min < 10 && hours > 0) ? `0${min}` : `${min}`; + const secStr = (sec < 10) ? `0${sec}` : `${sec}`; + + if (hours > 0) { + return `${hours}:${minStr}:${secStr}`; + } + return `${min}:${secStr}`; +} export function BigMedia(): Gtk.Widget { - let dragTimer: (AstalIO.Time|undefined); + const players = AstalPlayers.getDefault(); + + const playerSlider = createSlider({ + getValue: () => players.activePlayer?.get_position() ?? 0, + getMaxValue: () => players.activePlayer?.get_length() ?? 0, + setValue: (value) => players.activePlayer?.set_position(value), + getPlaybackStatus: () => players.activePlayer?.playback_status, + getColor: () => "#4285F4", + realtimeChangeValue: () => false, + typeSlider: () => typeSliders.MATERIAL_EXPRESSIVE_WAVE + }); return new Widget.Box({ className: "big-media", orientation: Gtk.Orientation.VERTICAL, homogeneous: false, width_request: 250, - visible: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? true : false), - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] && [ - new Widget.Box({ - halign: Gtk.Align.CENTER, - child: new Widget.Box({ - className: "image", - hexpand: false, - orientation: Gtk.Orientation.VERTICAL, - marginTop: 6, - visible: getAlbumArt(players[0]).as(Boolean), - css: getAlbumArt(players[0]).as((artUrl: string|undefined) => - artUrl ? `.image { background-image: url('${artUrl}'); }` : undefined), - width_request: 132, - height_request: 128 - } as Widget.BoxProps) - } as Widget.BoxProps), - new Widget.Box({ - className: "info", + visible: bind(players, "activePlayer").as(Boolean), + children: [ + new Widget.Box({ + halign: Gtk.Align.CENTER, + child: new Widget.Box({ + className: "image", + hexpand: false, orientation: Gtk.Orientation.VERTICAL, - vexpand: true, - valign: Gtk.Align.CENTER, - children: [ - new Widget.Label({ - className: "title", - tooltipText: bind(players[0], "title").as((title: string) => !title ? "No Title" : title), - label: bind(players[0], "title").as((title: string) => !title ? "No Title" : title), - truncate: true, - maxWidthChars: 25, - } as Widget.LabelProps), - new Widget.Label({ - className: "artist", - tooltipText: bind(players[0], "artist").as((artist: string) => !artist ? "No Artist" : artist), - label: bind(players[0], "artist").as((artist: string) => !artist ? "No Artist" : artist), - maxWidthChars: 28, - truncate: true, - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "progress", - hexpand: true, - visible: bind(players[0], "canSeek"), - children: [ - new Widget.Slider({ - min: 0, - hexpand: true, - max: bind(players[0], "length").as((length: number) => - Math.floor(length)), - value: bind(players[0], "position").as((position: number) => - Math.floor(position)), - onDragged: (slider: Widget.Slider) => { - if(dragTimer === undefined) - dragTimer = timeout(600, () => - players[0].set_position(Math.round(slider.value))); - else { - dragTimer.cancel(); - dragTimer = timeout(600, () => - players[0].set_position(Math.round(slider.value))); - } - } - }) - ] + marginTop: 6, + visible: bind(players, "activePlayer").as(p => p && p.cover_art), + css: bind(players, "activePlayer").as(p => + p?.cover_art ? `.image { background-image: url('${p.cover_art}'); }` : undefined), + width_request: 132, + height_request: 128 + }) + }), + new Widget.Box({ + className: "info", + orientation: Gtk.Orientation.VERTICAL, + vexpand: true, + valign: Gtk.Align.CENTER, + children: [ + new Widget.Label({ + className: "title", + tooltipText: bind(players, "activePlayer").as(p => p?.title ?? "No Title"), + label: bind(players, "activePlayer").as(p => p?.title ?? "No Title"), + truncate: true, + maxWidthChars: 25 + }), + new Widget.Label({ + className: "artist", + tooltipText: bind(players, "activePlayer").as(p => p?.artist ?? p?.get_identity() ?? "No Artist"), + label: bind(players, "activePlayer").as(p => p?.artist ?? p?.get_identity() ?? "No Artist"), + maxWidthChars: 28, + truncate: true + }) + ] + }), + new Widget.Box({ + className: "progress", + hexpand: true, + visible: bind(players, "activePlayer").as(p => p?.can_seek ?? false), + children: [ playerSlider ] + }), + new Widget.CenterBox({ + className: "bottom", + homogeneous: false, + hexpand: true, + marginBottom: 6, + startWidget: new Widget.Label({ + className: "elapsed", + valign: Gtk.Align.START, + halign: Gtk.Align.START, + label: bind(players, "activePlayer").as((p: AstalMpris.Player) => { + const pos = p?.position ?? 0; + const len = p?.length ?? 0; + return formatTime(pos > 0 && len > 0 ? pos : 0); + }) }), - new Widget.CenterBox({ - className: "bottom", - homogeneous: false, - hexpand: true, - marginBottom: 6, - startWidget: new Widget.Label({ - className: "elapsed", - valign: Gtk.Align.START, - halign: Gtk.Align.START, - label: bind(players[0], "position").as((pos: number) => { - const sec: number = Math.floor(pos % 60); - return pos > 0 && players[0].length > 0 ? - `${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}` - : `0:00`; - }) - } as Widget.LabelProps), - centerWidget: new Widget.Box({ - className: "controls button-row", - children: [ - new Widget.Button({ - className: "link", - image: new Widget.Icon({ - icon: "edit-paste-symbolic" - } as Widget.IconProps), - tooltipText: "Copy link to Clipboard", - visible: bind(players[0], "metadata").as(Boolean), - onClick: async () => { - const link = exec(`playerctl --player=${ - players[0].busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") - } metadata xesam:url`); - - link && Clipboard.getDefault().copyAsync(link); - } - } as Widget.ButtonProps), - new Widget.Button({ - className: "shuffle", - visible: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus !== AstalMpris.Shuffle.UNSUPPORTED), - image: new Widget.Icon({ - icon: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus === AstalMpris.Shuffle.ON ? - "media-playlist-shuffle-symbolic" - : "media-playlist-consecutive-symbolic") - } as Widget.IconProps), - tooltipText: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus === AstalMpris.Shuffle.ON ? - "Shuffle" - : "No shuffle"), - onClick: () => players[0].shuffle() - } as Widget.ButtonProps), - new Widget.Button({ - className: "previous", - image: new Widget.Icon({ - icon: "media-skip-backward-symbolic" - } as Widget.IconProps), - tooltipText: "Previous", - onClick: () => players[0].canGoPrevious && players[0].previous() - } as Widget.ButtonProps), - new Widget.Button({ - className: "pause", - tooltipText: bind(players[0], "playback_status").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play"), - image: new Widget.Icon({ - icon: bind(players[0], "playbackStatus").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic"), - } as Widget.IconProps), - onClick: () => players[0].playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? - players[0].play() - : players[0].pause() - } as Widget.ButtonProps), - new Widget.Button({ - className: "next", - image: new Widget.Icon({ - icon: "media-skip-forward-symbolic" - } as Widget.IconProps), - tooltipText: "Next", - onClick: () => players[0].canGoNext && players[0].next() - } as Widget.ButtonProps), - new Widget.Button({ - className: "repeat", - visible: bind(players[0], "loopStatus").as((loopStatus) => - loopStatus !== AstalMpris.Loop.UNSUPPORTED), - image: new Widget.Icon({ - icon: bind(players[0], "loopStatus").as((loopStatus) => { - switch(loopStatus) { - case AstalMpris.Loop.TRACK: - return "media-playlist-repeat-song-symbolic"; - - case AstalMpris.Loop.PLAYLIST: - return "media-playlist-repeat-symbolic"; - } - - return "loop-arrow-symbolic"; - }) - } as Widget.IconProps), - tooltipText: bind(players[0], "loopStatus").as((loopStatus) => { - switch(loopStatus) { + centerWidget: new Widget.Box({ + className: "controls button-row", + children: [ + // new Widget.Button({ + // className: "link", + // image: new Widget.Icon({ + // icon: "edit-paste-symbolic" + // } as Widget.IconProps), + // tooltipText: "Copy link to Clipboard", + // visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p.metadata ? true : false), + // onClickRelease: async () => { + // const link = exec(`playerctl --player=${ + // players.activePlayer.busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") + // } metadata xesam:url`); + // link && Clipboard.getDefault().copyAsync(link); + // } + // } as Widget.ButtonProps), + new Widget.Button({ + className: "shuffle", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p?.shuffleStatus !== AstalMpris.Shuffle.UNSUPPORTED), + image: new Widget.Icon({ + icon: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p?.shuffleStatus === AstalMpris.Shuffle.ON ? + "media-playlist-shuffle-symbolic" + : "media-playlist-consecutive-symbolic") + } as Widget.IconProps), + tooltipText: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p?.shuffleStatus === AstalMpris.Shuffle.ON ? + "Shuffle" + : "No shuffle"), + onClickRelease: () => players.activePlayer.shuffle() + } as Widget.ButtonProps), + new Widget.Button({ + className: "previous", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p.canGoPrevious ? true : false), + image: new Widget.Icon({ icon: "media-skip-backward-symbolic" }), + tooltipText: "Previous", + onClickRelease: () => players.activePlayer.canGoPrevious && players.activePlayer.previous() + }), + new Widget.Button({ + className: "play-pause", + tooltipText: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p.playback_status === AstalMpris.PlaybackStatus.PLAYING + ? "Pause" : "Play"), + image: new Widget.Icon({ + icon: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p.playback_status === AstalMpris.PlaybackStatus.PLAYING + ? "media-playback-pause-symbolic" : "media-playback-start-symbolic"), + }), + onClickRelease: () => players.activePlayer.play_pause() + }), + new Widget.Button({ + className: "next", + image: new Widget.Icon({ icon: "media-skip-forward-symbolic" }), + tooltipText: "Next", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => p?.can_go_next ? true : false), + onClickRelease: () => players.activePlayer.can_go_next && players.activePlayer.next() + }), + new Widget.Button({ + className: "repeat", + visible: bind(players, "activePlayer").as((p: AstalMpris.Player) => + p.loopStatus !== AstalMpris.Loop.UNSUPPORTED), + image: new Widget.Icon({ + icon: bind(players, "activePlayer").as((p: AstalMpris.Player) => { + switch(p.loopStatus) { case AstalMpris.Loop.TRACK: - return "Loop song"; - + return "media-playlist-repeat-song-symbolic"; case AstalMpris.Loop.PLAYLIST: - return "Loop playlist"; + return "media-playlist-repeat-symbolic"; } - - return "No loop"; - }), - onClick: () => players[0].loop() - } as Widget.ButtonProps) - ] - } as Widget.BoxProps), - endWidget: new Widget.Label({ - className: "length", - valign: Gtk.Align.START, - halign: Gtk.Align.END, - label: bind(players[0], "length").as((len/* bananananananana */: number) => { - const sec: number = Math.floor(len % 60); - return (len > 0 && Number.isFinite(len)) ? - `${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}` - : "0:00"; - }) - } as Widget.LabelProps) + return "loop-arrow-symbolic"; + }) + } as Widget.IconProps), + tooltipText: bind(players, "activePlayer").as((p: AstalMpris.Player) => { + switch(p.loopStatus) { + case AstalMpris.Loop.TRACK: + return "Loop song"; + case AstalMpris.Loop.PLAYLIST: + return "Loop playlist"; + } + return "No loop"; + }), + onClickRelease: () => players.activePlayer.loop() + } as Widget.ButtonProps) + ] + }), + endWidget: new Widget.Label({ + className: "length", + valign: Gtk.Align.START, + halign: Gtk.Align.END, + label: bind(players, "activePlayer").as(p => { + const len = p?.length ?? 0; + if (len <= 0) return "0:00"; + if (len > GLib.MAXINT64 / 10000000) return "Live"; + return formatTime(len); + }) }) - ]) - } as Widget.BoxProps); -} - - -/** - * This function handles album art/cover of playing media. If a file is provided - * by the player, it adds the "file://" uri as a prefix, so you can use it in css. - * - * @param player the player you want to pull album art from - * @returns Binding to player.artUrl containing the album art uri, or an undefined binding ig none was found. -* */ -function getAlbumArt(player: AstalMpris.Player): Binding { - return bind(player, "artUrl").as((artUrl: string) => { - - if(!artUrl) - return undefined; - - if(artUrl.startsWith("/")) - return "file://" + artUrl; - - return artUrl; + }) + ] }); } diff --git a/ags/widget/control-center/NotifHistory.ts b/ags/widget/control-center/NotifHistory.ts index a5728056..de1a3013 100644 --- a/ags/widget/control-center/NotifHistory.ts +++ b/ags/widget/control-center/NotifHistory.ts @@ -10,6 +10,46 @@ export const NotifHistory = () => { orientation: Gtk.Orientation.VERTICAL, className: bind(Notifications.getDefault(), "history").as(history => history.length > 0 ? "history" : "history hide"), children: [ + new Widget.Box({ + vexpand: false, + hexpand: true, + className: "top-row", + children: [ + new Widget.Box({ + className: "dnd-box", + hexpand: true, + halign: Gtk.Align.START, + children: [ + new Widget.Label({ + css: "margin-right: 6px;", + label: tr("control_center.tiles.dnd.title") + } as Widget.LabelProps), + new Widget.Switch({ + onNotifyActive: (self) => Notifications.getDefault().getNotifd().dontDisturb = self.active, + state: Notifications.getDefault().getNotifd().dontDisturb, + } as Widget.SwitchProps) + ] + + } as Widget.BoxProps), + new Widget.Button({ + css: `border-radius: 10px;`, + className: "clear-all", + halign: Gtk.Align.END, + child: new Widget.Box({ + children: [ + new Widget.Icon({ + css: "margin-right: 6px;", + icon: "edit-clear-all-symbolic" + } as Widget.IconProps), + new Widget.Label({ + label: tr("clear") + } as Widget.LabelProps) + ] + } as Widget.BoxProps), + onClick: () => Notifications.getDefault().clearHistory(), + } as Widget.ButtonProps) + ] + }), new Widget.Scrollable({ className: "history", hscroll: Gtk.PolicyType.NEVER, @@ -36,30 +76,7 @@ export const NotifHistory = () => { () => Notifications.getDefault().removeHistory(notification.id), true) )) } as Widget.BoxProps) - } as Widget.ScrollableProps), - new Widget.Box({ - vexpand: false, - hexpand: true, - halign: Gtk.Align.END, - className: "button-row", - children: [ - new Widget.Button({ - className: "clear-all", - child: new Widget.Box({ - children: [ - new Widget.Icon({ - css: "margin-right: 6px;", - icon: "edit-clear-all-symbolic" - } as Widget.IconProps), - new Widget.Label({ - label: tr("clear") - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - onClick: () => Notifications.getDefault().clearHistory(), - } as Widget.ButtonProps) - ] - }) + } as Widget.ScrollableProps) ] } as Widget.BoxProps); } diff --git a/ags/widget/control-center/pages/Network.ts b/ags/widget/control-center/pages/Network.ts index 3f39b774..c8f98b3e 100644 --- a/ags/widget/control-center/pages/Network.ts +++ b/ags/widget/control-center/pages/Network.ts @@ -3,6 +3,7 @@ import { Page, PageButton } from "./Page"; import AstalNetwork from "gi://AstalNetwork"; import { bind, GLib } from "astal"; import NM from "gi://NM"; +import NMA from "gi://NMA"; import { Windows } from "../../../windows"; import { tr } from "../../../i18n/intl"; import { execApp } from "../../../scripts/apps"; @@ -10,6 +11,11 @@ import { EntryPopup, EntryPopupProps } from "../../EntryPopup"; import { Notifications } from "../../../scripts/notifications"; import { AskPopup, AskPopupProps } from "../../AskPopup"; import { encoder } from "../../../scripts/utils"; +import { setOSDMode } from "../../../window/OSD"; + +const client = AstalNetwork.get_default().get_client(); +export const Network = AstalNetwork.get_default(); + export const PageNetwork: (() => Page) = () => new Page({ id: "network", @@ -21,10 +27,10 @@ export const PageNetwork: (() => Page) = () => new Page({ image: new Widget.Icon({ icon: "arrow-circular-top-right-symbolic" } as Widget.IconProps), - visible: bind(AstalNetwork.get_default(), "primary").as((primary) => + visible: bind(Network, "primary").as((primary) => primary === AstalNetwork.Primary.WIFI), tooltipText: "Re-scan connections", - onClick: () => AstalNetwork.get_default().wifi.scan() + onClick: () => Network.wifi.scan() } as Widget.ButtonProps) ], bottomButtons: [{ @@ -39,14 +45,13 @@ export const PageNetwork: (() => Page) = () => new Page({ className: "devices", hexpand: true, orientation: Gtk.Orientation.VERTICAL, - visible: bind(AstalNetwork.get_default().get_client(), "devices").as((devs) => devs.length > 0), - children: bind(AstalNetwork.get_default().get_client(), "devices").as((devices) => { + visible: bind(Network.get_client(), "devices").as((devs) => devs.length > 0), + children: bind(Network.get_client(), "devices").as((devices) => { devices = devices.filter(dev => dev.interface !== "lo"); return [ new Widget.Label({ label: tr("devices"), - xalign: 0, className: "sub-header", } as Widget.LabelProps), ...devices.filter(device => device.real).map(dev => PageButton({ @@ -57,6 +62,23 @@ export const PageNetwork: (() => Page) = () => new Page({ : "network-wired-symbolic"), title: bind(dev, "interface").as(iface => iface ?? tr("control_center.pages.network.interface")), + switches: [ + new Widget.Switch({ + css: `margin: 2px 0px; + font-size: 18px;`, + onNotifyActive: (self) => { + + const isDeviceActive = dev.state === NM.DeviceState.ACTIVATED + + if (self.active === isDeviceActive) { + return; + } + + controlConnection(dev, self.active) + }, + state: bind(dev, "state").as(state => state === NM.DeviceState.ACTIVATED ? true : false) + } as Widget.SwitchProps) + ], extraButtons: [ new Widget.Button({ image: new Widget.Icon({ @@ -172,21 +194,51 @@ export const PageNetwork: (() => Page) = () => new Page({ } as Widget.BoxProps) ] }); +// For switches +function controlConnection(device: (NM.Device|null), check: boolean): void { + + const activeConnection = device.get_active_connection() ?? null; + + if (check === true) { + const availableConnections = device.get_available_connections(); + + if (availableConnections.length <= 0) { + return; + } + const connection = availableConnections[0]; + client.activate_connection_async(connection, device, null, null, (_, asyncRes) => { + try { + console.log(`Activation ${device.interface} was successful.`); + } catch (e) { + console.error(`Activation error: ${e.message}`); + } + }); + } else { + client.deactivate_connection_async(activeConnection, null, (_, asyncRes) => { + try { + console.log(`Deactivation ${device.interface} was successful.`); + } catch (e) { + console.error(`Deactivation error: ${e.message}`); + } + }) + } +} function activateWirelessConnection(connection: NM.RemoteConnection, ssid: string): void { - AstalNetwork.get_default().get_client().activate_connection_async( - connection, AstalNetwork.get_default().wifi.get_device(), null, null, (_, asyncRes) => { - const activeConnection = AstalNetwork.get_default().get_client().activate_connection_finish(asyncRes); - if(!activeConnection) { - Notifications.getDefault().sendNotification({ - appName: "network", - summary: "Couldn't activate wireless connection", - body: `An error occurred while activating the wireless connection "${ssid}"` - }); - return; - } - } - ); + Network.get_client().activate_connection_async( + connection, Network.wifi.get_device(), null, null, (_, asyncRes) => errorNotif(asyncRes, ssid)); +} + +function errorNotif(asyncRes: any, ssid: string = ""): void { //change notify for connected and errors scenario + const activeConnection = Network.get_client().activate_connection_finish(asyncRes); + if(!activeConnection) { + Notifications.getDefault().sendNotification({ + appName: "network", + summary: "Couldn't activate wireless connection", + body: `An error occurred while activating the wireless connection "${ssid}"` + }); + return; + } } function notifyConnectionError(ssid: string): void { diff --git a/ags/widget/control-center/pages/Page.ts b/ags/widget/control-center/pages/Page.ts index a30f15aa..3a49de66 100644 --- a/ags/widget/control-center/pages/Page.ts +++ b/ags/widget/control-center/pages/Page.ts @@ -179,6 +179,7 @@ export function PageButton({ onDestroy, ...props }: { endWidget?: Gtk.Widget | Binding; description?: string | Binding; extraButtons?: Array | Binding>; + switches?: Array | Binding>; onDestroy?: (self: Widget.Box) => void; onClick?: (self: Widget.Button) => void; tooltipText?: string | Binding; @@ -213,7 +214,7 @@ export function PageButton({ onDestroy, ...props }: { new Widget.Label({ className: "title", xalign: 0, - // truncating is not working, so I had to do this + //truncate: true, label: (props.title instanceof Binding) ? props.title.as((title) => `${title.substring(0, 35)}${ @@ -221,7 +222,6 @@ export function PageButton({ onDestroy, ...props }: { : `${props.title.substring(0, 35)}${ props.title.length > 35 ? '…' : ""}`, tooltipText: props.title, - truncate: true, } as Widget.LabelProps), new Widget.Label({ className: "description", @@ -232,7 +232,7 @@ export function PageButton({ onDestroy, ...props }: { label: props.description, truncate: true, tooltipText: props.description - } as Widget.LabelProps) + } as Widget.LabelProps), ] } as Widget.BoxProps), new Widget.Box({ @@ -240,11 +240,16 @@ export function PageButton({ onDestroy, ...props }: { props.endWidget.as(Boolean) : props.endWidget, halign: Gtk.Align.END, - child: props.endWidget + child: props.endWidget, } as Widget.BoxProps) ] } as Widget.BoxProps) } as Widget.ButtonProps), + new Widget.Box({ + //className: "switches", + visible: (props.switches instanceof Binding) ? props.switches.as(Boolean) : Boolean(props.switches), + children: props.switches + } as Widget.BoxProps), new Widget.Box({ className: "extra-buttons button-row", visible: (props.extraButtons instanceof Binding) ? diff --git a/ags/widget/control-center/pages/Sound.ts b/ags/widget/control-center/pages/Sound.ts index cd0d9fce..2cf71ec0 100644 --- a/ags/widget/control-center/pages/Sound.ts +++ b/ags/widget/control-center/pages/Sound.ts @@ -1,9 +1,10 @@ import { Page, PageButton, PageProps } from "./Page"; import { bind, Variable } from "astal"; import { Astal, Gtk, Widget } from "astal/gtk3"; -import { getAppIcon } from "../../../scripts/apps"; +import { getAppIcon, getIconByAppName, getSymbolicIcon } from "../../../scripts/apps"; import { Wireplumber } from "../../../scripts/volume"; import { tr } from "../../../i18n/intl"; +import { analyser } from "../../../scripts/utils"; export function PageSound(): Page { const endpoints = Variable.derive([ @@ -26,7 +27,7 @@ export function PageSound(): Page { PageButton({ className: bind(speaker, "isDefault").as(isDefault => isDefault ? "default" : ""), icon: bind(speaker, "icon").as(icon => - Astal.Icon.lookup_icon(icon)? icon : "audio-card-symbolic"), + getSymbolicIcon(icon) ?? "audio-card-symbolic"), title: bind(speaker, "description").as(desc => desc ?? "Speaker"), onClick: () => speaker.set_is_default(true), endWidget: new Widget.Icon({ @@ -52,9 +53,9 @@ export function PageSound(): Page { orientation: Gtk.Orientation.HORIZONTAL, children: [ new Widget.Icon({ - icon: bind(stream, "name").as(name => - getAppIcon(name.split(' ')[0]) ?? "application-x-executable-symbolic"), - css: "font-size: 18px; margin-right: 6px;" + icon: bind(stream, "description").as(icon => + getSymbolicIcon(icon) ?? getIconByAppName(icon) ?? "application-x-executable-symbolic"), + css: "font-size: 20px; margin-right: 6px;" } as Widget.IconProps), new Widget.Box({ orientation: Gtk.Orientation.VERTICAL, @@ -69,7 +70,11 @@ export function PageSound(): Page { ), onDestroy: () => connections.map(id => eventbox.disconnect(id)), child: new Widget.Label({ - label: bind(stream, "name").as(name => name || "Unknown"), + label: bind(stream, "description").as(desc => { //need to add filter for "audio stream1" + const maxLength = (35 - (desc.length + 3)); + let title = `${stream.name.substring(0, maxLength)}${stream.name.length >= maxLength ? '...' : ""}` + return `${desc} - ${title}`; + }), truncate: true, tooltipText: bind(stream, "name"), className: "name", diff --git a/ags/window/CenterWindow.ts b/ags/window/CenterWindow.ts index ccccc96b..b0e3d353 100644 --- a/ags/window/CenterWindow.ts +++ b/ags/window/CenterWindow.ts @@ -5,7 +5,7 @@ import { getDateTime } from "../scripts/time"; import { Separator, SeparatorProps } from "../widget/Separator"; import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; import { BigMedia } from "../widget/center-window/BigMedia"; -import AstalMpris from "gi://AstalMpris"; +import { AstalPlayers } from "../scripts/player"; export const CenterWindow = (mon: number) => PopupWindow({ namespace: "center-window", @@ -60,7 +60,7 @@ export const CenterWindow = (mon: number) => PopupWindow({ margin: 5, spacing: 8, alpha: .3, - visible: bind(AstalMpris.get_default(), "players").as(players => players.length > 0), + visible: bind(AstalPlayers.getDefault(), "activePlayer").as(players => players ? true : false), } as SeparatorProps), BigMedia() ] diff --git a/ags/window/OSD.ts b/ags/window/OSD.ts index ddc86550..3ed5195a 100644 --- a/ags/window/OSD.ts +++ b/ags/window/OSD.ts @@ -1,13 +1,43 @@ -import { bind, Variable } from "astal"; +import { bind, Binding, Variable } from "astal"; import { Astal, Gtk, Widget } from "astal/gtk3"; import { Wireplumber } from "../scripts/volume"; +import AstalWp from "gi://AstalWp"; +import AstalHyprland from "gi://AstalHyprland"; +import { AstalPlayers } from "../scripts/player"; +import { getSymbolicIcon } from "../scripts/apps"; export enum OSDModes { SINK, - BRIGHTNESS + SOURCE, + LAYOUT, + //BRIGHTNESS, + //CAPSLOCK, + //NUMLOCK, + //PLAYER } let osdMode: (Variable|null); +const layoutVar = new Variable(""); +let WireplumberObject: object|null; + +export function variableHandler(mode: OSDModes, value: any) { + switch (mode) { + case OSDModes.LAYOUT: + if (layoutVar === value) return; + layoutVar.set(value.substring(0, 2).toUpperCase()); + break; + + case OSDModes.SINK: + WireplumberObject = value; + console.log(`Sink mute status: ${typeof value}`); + break; + + case OSDModes.SOURCE: + WireplumberObject = value; + console.log(`Source mute status: ${typeof value}`); + break; + } +} export function setOSDMode(newMode: OSDModes): void { if(!osdMode) return; @@ -15,8 +45,186 @@ export function setOSDMode(newMode: OSDModes): void { osdMode.set(newMode); } +function createOSD( + props: Widget.BoxProps, + iconName: string | Binding, + labelOSD?: string | Binding, +) { + return new Widget.Box({ + ...props, + className: `osd ${props.className || ''}`, + expand: true, + children: [ + new Widget.Icon({ + className: "icon", + icon: iconName, + } as Widget.IconProps), + new Widget.Label({ + className: "action", + visible: labelOSD ? true : false, + label: labelOSD, + } as Widget.LabelProps), + ] + } as Widget.BoxProps) +} + +function createOSDSublabel( + props: Widget.BoxProps, + iconName: string | Binding, + labelOSD?: string | Binding, + sublabelOSD?: string | Binding +) { + return new Widget.Box({ + ...props, + className: `osd ${props.className || ''}`, + expand: true, + children: [ + new Widget.Icon({ + className: "icon", + icon: iconName, + } as Widget.IconProps), + new Widget.Box({ + vertical: true, + children: [ + new Widget.Label({ + className: "action", + visible: labelOSD ? true : false, + label: labelOSD, + } as Widget.LabelProps), + new Widget.Label({ + className: "sublabel", + visible: sublabelOSD ? true : false, + label: sublabelOSD, + } as Widget.LabelProps) + ] + }) + ] + } as Widget.BoxProps) +} + +function createOSDLevelBar( + props: Widget.BoxProps, + bindable: AstalWp.Endpoint, + iconName: string | Binding, + labelOSD: string | Binding, + valueOSD: number | Binding, + maxValueOSD: number | Binding + +) { + return new Widget.Box({ + ...props, + className: `osd ${props.className || ''}`, + expand: true, + children: [ + new Widget.Icon({ + className: "icon", + icon: iconName, + } as Widget.IconProps), + new Widget.Box({ + className: "volume", + orientation: Gtk.Orientation.VERTICAL, + valign: Gtk.Align.CENTER, + children: [ + new Widget.Label({ + className: "device", + label: labelOSD, + truncate: true, + } as Widget.LabelProps), + new Widget.Box({ + vexpand: false, + expand: false, + children: [ + new Widget.LevelBar({ + className: "levelbar", + width_request: 140, + value: valueOSD, + maxValue: maxValueOSD, + expand: true, + } as Widget.LevelBarProps), + /*new Widget.Label({ + className: "value", + label: bind(Wireplumber.getDefault().getDefaultSink(), "volume").as((volume: number) => + `${Math.floor(volume * 100)}%`), + vexpand: false, + expand: false, + halign: Gtk.Align.CENTER + } as Widget.LabelProps)*/ + ] + } as Widget.BoxProps) + ] + } as Widget.BoxProps) + ] + } as Widget.BoxProps) +} + +function OSDSink() { + const audio = Wireplumber.getDefault().getDefaultSink(); + return createOSDLevelBar( + { name: "sink" }, + audio, + bind(audio, "volumeIcon").as(icon => + !Wireplumber.getDefault().isMutedSink() && Wireplumber.getDefault().getSinkVolume() > 0 ? + icon : "audio-volume-muted-symbolic"), + bind(audio, "description").as((description: string) => + description || "Speaker"), + bind(audio, "volume").as((volume: number) => + Math.floor(volume * 100)), + bind(Wireplumber.getWireplumber(), "defaultSpeaker").as(() => + Wireplumber.getDefault().getMaxSinkVolume()) + ) +} + +function OSDSource() { + const source = Wireplumber.getDefault().getDefaultSource(); + return createOSDLevelBar( + { name: "source" }, + source, + bind(source, "volumeIcon").as(icon => + !Wireplumber.getDefault().isMutedSource() && Wireplumber.getDefault().getSourceVolume() > 0 ? + icon : "microphone-sensitivity-muted-symbolic"), + bind(source, "description").as((description: string) => + description || "Microphone"), + bind(source, "volume").as((volume: number) => + Math.floor(volume * 100)), + bind(Wireplumber.getWireplumber(), "defaultMicrophone").as(() => + Wireplumber.getDefault().getMaxSourceVolume()) + ) +} + +//need to do more work here +function OSDPlayer() { + const player = AstalPlayers.getDefault().activePlayer; + return createOSDSublabel( + { name: "player" }, + bind(AstalPlayers.getDefault(), "activePlayer").as(activePlayer => getSymbolicIcon(activePlayer.get_entry()) || + getSymbolicIcon(activePlayer.get_bus_name().split('.').filter(str => !str.toLowerCase().includes('instance')).join('.')) || + "folder-music-symbolic"), + bind(player, "title").as(title => title ? title : ""), + bind(player, "artist").as(artist => artist ? artist : (player.get_identity() || "")) + ) + /*return createOSDLevelBar( + { name: "player" }, + player, + bind(AstalPlayers.getDefault(), "activePlayer").as(activePlayer => getSymbolicIcon(activePlayer.get_entry()) ?? + getSymbolicIcon(activePlayer.get_bus_name().split('.').filter(str => !str.toLowerCase().includes('instance')).join('.')) ?? + "folder-music-symbolic"), + bind(player, "title"), + bind(player, "position"), + bind(player, "length") + )*/ +} + +function OSDLayout() { + const hyprland = AstalHyprland.get_default(); + return createOSD( + { name: "layout" }, + "input-keyboard-symbolic", + bind(layoutVar, "value") + ) +} + export const OSD = (mon: number) => { - osdMode = new Variable(OSDModes.SINK); + osdMode = new Variable(); return new Widget.Window({ namespace: "osd", @@ -24,49 +232,22 @@ export const OSD = (mon: number) => { layer: Astal.Layer.OVERLAY, anchor: Astal.WindowAnchor.BOTTOM, canFocus: false, - clickThrough: true, - focusOnClick: false, marginBottom: 80, + focusOnClick: false, + clickThrough: true, monitor: mon, onDestroy: () => { osdMode?.drop(); osdMode = null; + WireplumberObject = null; }, - child: new Widget.Box({ - className: "osd", - expand: true, - children: [ - new Widget.Icon({ - className: "icon", - icon: bind(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon => - !Wireplumber.getDefault().isMutedSink() && Wireplumber.getDefault().getSinkVolume() > 0 ? icon : "audio-volume-muted-symbolic"), - } as Widget.IconProps), - new Widget.Box({ - className: "volume", - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.CENTER, - expand: true, - children: [ - new Widget.Label({ - className: "device", - label: bind(Wireplumber.getDefault().getDefaultSink(), "description").as(description => - description ?? "Speaker"), - truncate: true, - } as Widget.LabelProps), - new Widget.Box({ - expand: true, - child: new Widget.LevelBar({ - className: "levelbar", - value: bind(Wireplumber.getDefault().getDefaultSink(), "volume").as((volume: number) => - Math.floor(volume * 100)), - maxValue: bind(Wireplumber.getWireplumber(), "defaultSpeaker").as(() => - Wireplumber.getDefault().getMaxSinkVolume()), - expand: true - } as Widget.LevelBarProps) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps) + children: bind(osdMode, "value").as((mode: OSDModes) => { + switch (mode) { + case OSDModes.SINK: return OSDSink(); + case OSDModes.SOURCE: return OSDSource(); + case OSDModes.LAYOUT: return OSDLayout(); + case OSDModes.PLAYER: return OSDPlayer(); + } + }), } as Widget.WindowProps); }