diff --git a/src/app.ts b/src/app.ts index e232b6c..30dbc61 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,17 +18,17 @@ import { Wallpaper } from "./modules/wallpaper"; import { Stylesheet } from "./modules/stylesheet"; import { Clipboard } from "./modules/clipboard"; import { Gdk, Gtk } from "ags/gtk4"; -import { createBinding, createRoot, getScope, Scope } from "ags"; +import { createBinding, createComputed, createRoot, getScope, Scope } from "ags"; import { OSDModes, triggerOSD } from "./window/osd"; import { programArgs, programInvocationName } from "system"; import { setConsoleLogDomain } from "console"; -import { initPlayer } from "./modules/media"; import { createSubscription, encoder, secureBaseBinding } from "./modules/utils"; import { exec } from "ags/process"; import { NightLight } from "./modules/nightlight"; import { Backlights } from "./modules/backlight"; import GObject, { register } from "ags/gobject"; +import Media from "./modules/media"; import GLib from "gi://GLib?version=2.0"; import Gio from "gi://Gio?version=2.0"; import Adw from "gi://Adw?version=1"; @@ -275,7 +275,7 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re NightLight.getDefault(); - initPlayer(); + Media.getDefault(); Clipboard.getDefault(); console.log("Colorshell: Initializing Wallpaper and Stylesheet modules"); @@ -286,21 +286,14 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re runnerPlugins.forEach(plugin => Runner.addPlugin(plugin)); createSubscription( - secureBaseBinding( - createBinding(AstalWp.get_default(), "defaultSpeaker"), - "volume", - null - ), - () => !Windows.getDefault().isOpen("control-center") && - triggerOSD(OSDModes.sink) - ); - - createSubscription( - secureBaseBinding( - createBinding(AstalWp.get_default(), "defaultSpeaker"), - "mute", - null - ), + createComputed([ + secureBaseBinding(createBinding( + AstalWp.get_default(), "defaultSpeaker" + ), "volume", null), + secureBaseBinding(createBinding( + AstalWp.get_default(), "defaultSpeaker" + ), "mute", null) + ]), () => !Windows.getDefault().isOpen("control-center") && triggerOSD(OSDModes.sink) ); diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e7bb39..943bf99 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,5 @@ import { Scope } from "ags"; -import { createScopedConnection, encoder } from "../modules/utils"; +import { createScopedConnection, decoder, encoder } from "../modules/utils"; import windows from "./modules/windows"; import volume from "./modules/volume"; @@ -11,7 +11,6 @@ import GLib from "gi://GLib?version=2.0"; export namespace Cli { let rootScope: Scope; - let service: Gio.SocketService; let initialized: boolean = false; const modules: Array = [ // main module, no need for prefix @@ -65,7 +64,12 @@ export namespace Cli { value?: string; /** help message for the argument */ help?: string; - onCalled: (value?: string) => void; + onCalled?: (value?: string) => void; + }; + + export type ArgumentData = { + argument: Argument; + data?: string; }; export type Command = { @@ -76,7 +80,7 @@ export namespace Cli { /** data passed to the command. (only works when arguments are disabled) */ data?: string; arguments?: Array; - onCalled: (args: Array, data?: string) => Output; + onCalled: (args: Array, data?: string) => Output; }; export type Module = { @@ -91,22 +95,57 @@ export namespace Cli { }; /** initialize the cli */ - export function init(scope: Scope, socketService: Gio.SocketService): void { + export function init(scope: Scope, communicationMethod: Gio.SocketService|Gio.ApplicationCommandLine, app?: Gio.Application): void { if(initialized) return; initialized = true; rootScope = scope; - service = socketService; DEVEL && modules.push(devel); scope.run(() => { - createScopedConnection( - service, "incoming", (conn) => { - try { - return handleIncoming(conn); - } catch(_) {} + if(communicationMethod instanceof Gio.SocketService) { + createScopedConnection( + communicationMethod, "incoming", (conn) => { + try { + return handleIncoming(conn); + } catch(_) {} - return false; + return false; + } + ); + + return; + } + + if(!app) + throw new Error("GApplication not specified for GApplicationCommandLine communication method") + if(app.flags !& Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + throw new Error("GApplication does not have the HANDLES_COMMAND_LINE flag or doesn't implement it") + + createScopedConnection( + app, + "command-line", + (cmd) => { + let hasError: boolean = false; + try { + handleArgs( + cmd.get_arguments().toSpliced(0, 1), + (str, type) => { + if(type === "err") { + cmd.printerr_literal(str); + hasError = true; + return; + } + + cmd.print_literal(str); + } + ); + } catch(_) { + // TODO better error message + hasError = true; + } + + return hasError ? 1 : 0; } ); }); @@ -131,7 +170,7 @@ export namespace Cli { parsedArgs?.splice(0, 1); // remove the unnecessary `colorshell` part if(success) { - handleArgs(parsedArgs!); + handleArgs(parsedArgs!, conn.outputStream); conn.outputStream.flush(null); conn.close(null); @@ -153,8 +192,43 @@ export namespace Cli { }); } - /** translate app arguments to modules/commands */ - function handleArgs(args: Array): void { + /** translate app arguments to modules/commands + * order: module ?arg -> command ?arg */ + function handleArgs(args: Array, writeTo: Gio.OutputStream|((str: string, type: "out"|"err") => void)): void { let mod: Module; + let command: Command|undefined; + const modArgs: Array = []; + const cmdArgs: Array = []; + + function print(out: Output): void { + const content = `${outputToString(out)}\n`; + const type: "out"|"err" = typeof out === "object" ? + out.type + : "out"; + + typeof writeTo === "function" ? + writeTo(content, type) + : writeTo.write_bytes( + encoder.encode(`${outputToString(out)}\n`), + null + ); + } + + for(let i = 0; i < args.length; i++) { + const arg = args[i]; + + if(i === 0) { + + } + } + } + + function outputToString(out: Output): string { + if(typeof out === "object") + return out.content instanceof Uint8Array ? + decoder.decode(out.content) + : out.content; + + return out; } } diff --git a/src/modules/arg-handler.ts b/src/modules/arg-handler.ts index 2453c70..7ba59db 100644 --- a/src/modules/arg-handler.ts +++ b/src/modules/arg-handler.ts @@ -6,10 +6,10 @@ import { timeout } from "ags/time"; import { Runner } from "../runner/Runner"; import { showWorkspaceNumber } from "../window/bar/widgets/Workspaces"; import { playSystemBell } from "./utils"; -import { player, setPlayer } from "./media"; import { Shell } from "../app"; import { generalConfig } from "../config"; +import Media from "./media"; import AstalIO from "gi://AstalIO"; import AstalMpris from "gi://AstalMpris"; @@ -166,8 +166,8 @@ Options: return 0; } - const activePlayer: AstalMpris.Player|undefined = player.get().available ? - player.get() + const activePlayer: AstalMpris.Player|undefined = Media.getDefault().player.available ? + Media.getDefault().player : undefined; const players = AstalMpris.get_default().players.filter(pl => pl.available); @@ -250,7 +250,7 @@ specified bus name does not exist/is not available!`); return 1; } - setPlayer(players.filter(pl => pl.busName === args[2])[0]); + Media.getDefault().player = players.filter(pl => pl.busName === args[2])[0]; cmd.print_literal(`Done setting player to \`${args[2]}\`!`); return 0; } diff --git a/src/modules/media.ts b/src/modules/media.ts index 3e06174..dd4c8ad 100644 --- a/src/modules/media.ts +++ b/src/modules/media.ts @@ -1,77 +1,86 @@ -import { Accessor, createConnection, createRoot, createState, onCleanup } from "ags"; -import { decoder } from "./utils"; +import { Accessor, createConnection, getScope, Scope } from "ags"; +import { createScopedConnection, decoder } from "./utils"; -import GObject from "ags/gobject"; import AstalMpris from "gi://AstalMpris"; +import GObject from "gi://GObject?version=2.0"; +import { property, register } from "ags/gobject"; -export const dummyPlayer = { - available: false, - busName: "dummy_player", - bus_name: "dummy_player" -} as AstalMpris.Player; +@register({ GTypeName: "Media" }) +export default class Media extends GObject.Object { + private static instance: Media; + public static readonly dummyPlayer = { + available: false, + busName: "dummy_player", + bus_name: "dummy_player" + } as AstalMpris.Player; -export let [player, setPlayer] = createState(dummyPlayer); + @property(AstalMpris.Player) + player: AstalMpris.Player = Media.dummyPlayer; -let disposeFun: undefined|(() => void); + constructor(scope: Scope) { + super(); + + scope.run(() => { + const firstPlayer = AstalMpris.get_default().players[0]; + if(firstPlayer) + this.player = firstPlayer; -export function initPlayer(): void { - if(disposeFun) { - console.error("Media: cannot initialize, there's already an instance"); - return; + createScopedConnection( + AstalMpris.get_default(), + "player-added", + (player) => { + if(player.available) + this.player = player; + } + ); + + createScopedConnection( + AstalMpris.get_default(), + "player-closed", (closedPlayer) => { + const players = AstalMpris.get_default().players.filter(pl => pl?.available && + pl.busName !== closedPlayer.busName); + + // go back to first player(if available) when the active player is closed + if(players.length > 0 && players[0]) { + this.player = players[0]; + return; + } + + this.player = Media.dummyPlayer; + } + ); + }); } - createRoot((dispose) => { - const connections = new Map>(); - disposeFun = dispose; + public static getDefault(): Media { + if(!this.instance) + this.instance = new Media(getScope()); - setPlayer(AstalMpris.get_default().players[0] ?? dummyPlayer); + return this.instance; + } - connections.set(AstalMpris.get_default(), [ - AstalMpris.get_default().connect("player-added", (_, player) => - player.available && setPlayer(player)), + public static accessMediaUrl(player: AstalMpris.Player): Accessor { + return createConnection(player.get_meta("xesam:url"), + [player, "notify::metadata", () => player.get_meta("xesam:url")] + ).as(url => { + const byteString = url?.get_data_as_bytes(); - AstalMpris.get_default().connect("player-closed", (_, closedPlayer) => { - const players = AstalMpris.get_default().players.filter(pl => pl?.available && - pl.busName !== closedPlayer.busName); + return byteString ? + decoder.decode(byteString.toArray()) + : undefined; + }) + } - if(players.length > 0 && players[0]) { - setPlayer(players[0]); - return; - } - - setPlayer(dummyPlayer); - }) - ]); + + public static getMediaUrl(player: AstalMpris.Player): string|undefined { + if(!player.available) return; - onCleanup(() => { - connections.forEach((ids, obj) => - Array.isArray(ids) ? - ids.forEach(id => obj.disconnect(id)) - : obj.disconnect(ids) - ); - disposeFun = undefined; - }); - }); -} - -export function accessMediaUrl(player: AstalMpris.Player): Accessor { - return createConnection(player.get_meta("xesam:url"), - [player, "notify::metadata", () => player.get_meta("xesam:url")] - ).as(url => { - const byteString = url?.get_data_as_bytes(); + const meta = player.get_meta("xesam:url"); + const byteString = meta?.get_data_as_bytes(); return byteString ? decoder.decode(byteString.toArray()) : undefined; - }) -} - -export function disposePlayer(): void { - if(disposeFun) { - disposeFun(); - return; } - - console.error("Media: Couldn't dispose player, there's no instance to dispose of"); } diff --git a/src/runner/plugins/media.ts b/src/runner/plugins/media.ts index 939ab30..a5e6f6f 100644 --- a/src/runner/plugins/media.ts +++ b/src/runner/plugins/media.ts @@ -1,53 +1,99 @@ import { createBinding, createComputed } from "ags"; import { Runner } from "../Runner"; -import { player } from "../../modules/media"; +import { secureBaseBinding } from "../../modules/utils"; +import { tr } from "../../i18n/intl"; +import Media from "../../modules/media"; import AstalMpris from "gi://AstalMpris"; export const PluginMedia = { prefix: ":", - handle: () => !player.get().available ? { + handle: () => !Media.getDefault().player.available ? { icon: "folder-music-symbolic", title: "Couldn't find any players", closeOnClick: false, description: "No media / player found with mpris" } : [ { - icon: createBinding(player.get(), "playbackStatus").as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? + icon: secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? "media-playback-pause-symbolic" : "media-playback-start-symbolic"), closeOnClick: false, title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist"), - createBinding(player.get(), "playbackStatus") - ], (title, artist, status) => `${ status === AstalMpris.PlaybackStatus.PLAYING ? + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ) + ], (title, artist, status) => `${status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play" } ${title} | ${artist}`), - actionClick: () => player.get().play_pause() + actionClick: () => Media.getDefault().player.play_pause() }, { icon: "media-skip-backward-symbolic", closeOnClick: false, title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist") - ], (title, artist) => - `Go Previous ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "identity", + "Music Player" + ) + ], (title, artist, identity) => + `Go Previous ${title ? title : identity}${artist ? ` | ${artist}` : ""}` ), - actionClick: () => player.get().canGoPrevious && player.get().previous() + actionClick: () => Media.getDefault().player.canGoPrevious && + Media.getDefault().player.previous() }, { icon: "media-skip-forward-symbolic", closeOnClick: false, title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist") - ], (title, artist) => - `Go Next ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "identity", + "Music Player" + ) + ], (title, artist, identity) => + `Go Next ${title ? title : identity}${artist ? ` | ${artist}` : ""}` ), - actionClick: () => player.get().canGoNext && player.get().next() + actionClick: () => Media.getDefault().player.canGoNext && + Media.getDefault().player.next() } ] } as Runner.Plugin; diff --git a/src/window/bar/widgets/Media.tsx b/src/window/bar/widgets/Media.tsx index f6fbf17..eef34e0 100644 --- a/src/window/bar/widgets/Media.tsx +++ b/src/window/bar/widgets/Media.tsx @@ -1,32 +1,25 @@ -import { createBinding, onCleanup, With } from "ags"; +import { createBinding, With } from "ags"; import { Gtk } from "ags/gtk4"; import { Separator } from "../../../widget/Separator"; import { Windows } from "../../../windows"; import { Clipboard } from "../../../modules/clipboard"; -import { getPlayerIconFromBusName, variableToBoolean } from "../../../modules/utils"; -import { accessMediaUrl, player, setPlayer } from "../../../modules/media"; - -import GObject from "ags/gobject"; -import AstalMpris from "gi://AstalMpris"; -import Pango from "gi://Pango?version=1.0"; +import { getPlayerIconFromBusName, secureBaseBinding, variableToBoolean } from "../../../modules/utils"; import { tr } from "../../../i18n/intl"; +import { default as Player } from "../../../modules/media"; +import AstalMpris from "gi://AstalMpris"; +import Pango from "gi://Pango?version=1.0"; -export const Media = () => { - const connections: Map|number> = new Map(); - onCleanup(() => connections.forEach((id, obj) => - Array.isArray(id) ? - id.forEach(id => obj.disconnect(id)) - : obj.disconnect(id) - )); - - return pl.available)}> +export const Media = () => + (createBinding( + Player.getDefault(), "player" + ), "available", false)}> { self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) }} onScroll={(_, __, dy) => { if(AstalMpris.get_default().players.length === 1 && - player.get()?.busName === AstalMpris.get_default().players[0].busName) + Player.getDefault().player.busName === AstalMpris.get_default().players[0].busName) return true; const players = AstalMpris.get_default().players; @@ -34,16 +27,16 @@ export const Media = () => { for(let i = 0; i < players.length; i++) { const pl = players[i]; - if(pl.busName !== player.get().busName) + if(pl.busName !== Player.getDefault().player.busName) continue; if(dy > 0 && players[i-1]) { - setPlayer(players[i-1]); + Player.getDefault().player = players[i-1]; break; } if(dy < 0 && players[i+1]) { - setPlayer(players[i+1]); + Player.getDefault().player = players[i+1]; break; } } @@ -60,19 +53,33 @@ export const Media = () => { revealer.set_reveal_child(false); }} /> - pl.available)}> - pl.available)}> + (createBinding( + Player.getDefault(), "player" + ), "available", false) + }> + (createBinding( + Player.getDefault(), "player" + ), "available", false) + }> {(available: boolean) => available && (createBinding( + Player.getDefault(), "player" + ), "busName", "org.MediaPlayer2.folder-music-symbolic").as( + getPlayerIconFromBusName + )} /> - - title ?? tr("media.no_title"))} maxWidthChars={20} ellipsize={Pango.EllipsizeMode.END} + (createBinding( + Player.getDefault(), "player" + ), "title", "").as(title => title ?? tr("media.no_title"))} + maxWidthChars={20} ellipsize={Pango.EllipsizeMode.END} /> - - artist ?? tr("media.no_artist"))} maxWidthChars={18} ellipsize={Pango.EllipsizeMode.END} + (createBinding( + Player.getDefault(), "player" + ), "artist", "").as(artist => artist ?? tr("media.no_artist"))} + maxWidthChars={18} ellipsize={Pango.EllipsizeMode.END} /> } @@ -80,13 +87,16 @@ export const Media = () => { - pl.available)}> + (createBinding( + Player.getDefault(), "player" + ), "available", false) + }> {(available: boolean) => available && { - const url = accessMediaUrl(player.get()).get(); + const url = Player.getMediaUrl(Player.getDefault().player); url && Clipboard.getDefault().copyAsync(url); }} /> @@ -94,25 +104,32 @@ export const Media = () => { - player.get().canGoPrevious && player.get().previous()} + Player.getDefault().player.canGoPrevious && + Player.getDefault().player.previous() + } /> - - status === AstalMpris.PlaybackStatus.PAUSED ? - "media-playback-start-symbolic" - : "media-playback-pause-symbolic")} - tooltipText={createBinding(player.get(), "playbackStatus").as(status => - status === AstalMpris.PlaybackStatus.PAUSED ? - tr("media.play") - : tr("media.pause") - )} onClicked={() => player.get().play_pause()} + ( + createBinding(Player.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as(status => status === AstalMpris.PlaybackStatus.PAUSED ? + "media-playback-start-symbolic" + : "media-playback-pause-symbolic" + )} + tooltipText={secureBaseBinding( + createBinding(Player.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as(status => status === AstalMpris.PlaybackStatus.PAUSED ? + tr("media.play") : tr("media.pause") + )} onClicked={() => Player.getDefault().player.play_pause()} /> player.get().canGoNext && - player.get().next()} + tooltipText={tr("media.next")} onClicked={() => Player.getDefault().player.canGoNext && + Player.getDefault().player.next()} /> } - -} + as Gtk.Box; diff --git a/src/window/center-window/index.tsx b/src/window/center-window/index.tsx index 5efde94..0c0be74 100644 --- a/src/window/center-window/index.tsx +++ b/src/window/center-window/index.tsx @@ -4,17 +4,18 @@ import { PopupWindow } from "../../widget/PopupWindow"; import { BigMedia } from "./widgets/BigMedia"; import { time, variableToBoolean } from "../../modules/utils"; import { createBinding } from "ags"; -import { player } from "../../modules/media"; +import Media from "../../modules/media"; import AstalMpris from "gi://AstalMpris"; + export const CenterWindow = (mon: number) => { if(keyval === Gdk.KEY_space) { - player.get().available && - player.get().play_pause(); + Media.getDefault().player.available && + Media.getDefault().player.play_pause(); return true; } }}> diff --git a/src/window/center-window/widgets/BigMedia.tsx b/src/window/center-window/widgets/BigMedia.tsx index e610cae..f2a4a7a 100644 --- a/src/window/center-window/widgets/BigMedia.tsx +++ b/src/window/center-window/widgets/BigMedia.tsx @@ -2,19 +2,16 @@ import { createBinding, For } from "ags"; import { register } from "ags/gobject"; import { Astal, Gtk } from "ags/gtk4"; import { Clipboard } from "../../../modules/clipboard"; -import { accessMediaUrl } from "../../../modules/media"; -import { player, setPlayer } from "../../../modules/media"; import { pathToURI, variableToBoolean } from "../../../modules/utils"; import { tr } from "../../../i18n/intl"; +import Media from "../../../modules/media"; import AstalMpris from "gi://AstalMpris"; import Pango from "gi://Pango?version=1.0"; import Adw from "gi://Adw?version=1"; import GLib from "gi://GLib?version=2.0"; -let dragTimer: (GLib.Source|undefined); - export const BigMedia = () => { const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(pls => pls.filter(p => p.available)); @@ -22,11 +19,11 @@ export const BigMedia = () => { const carousel = { const page = self.get_nth_page(num); - if(page instanceof PlayerWidget && player.get().busName !== page.player.busName) - setPlayer(page.player); + if(page instanceof PlayerWidget && Media.getDefault().player.busName !== page.player.busName) + Media.getDefault().player = page.player; }}> players.sort(pl => - pl.busName === player.get().busName ? -1 : 1))}> + pl.busName === Media.getDefault().player.busName ? -1 : 1))}> {(player: AstalMpris.Player) => } @@ -48,6 +45,7 @@ export const BigMedia = () => { class PlayerWidget extends Gtk.Box { #player!: AstalMpris.Player; #copyClickTimeout?: GLib.Source; + #dragTimer?: GLib.Source; get player() { return this.#player; } @@ -97,16 +95,16 @@ class PlayerWidget extends Gtk.Box { onChangeValue={(_, type, value) => { if(type == null) return; - if(!dragTimer) { - dragTimer = setTimeout(() => + if(!this.#dragTimer) { + this.#dragTimer = setTimeout(() => player.position = Math.floor(value) , 200); return; } - dragTimer.destroy(); - dragTimer = setTimeout(() => + this.#dragTimer?.destroy(); + this.#dragTimer = setTimeout(() => player.position = Math.floor(value) , 200); }} @@ -129,9 +127,9 @@ class PlayerWidget extends Gtk.Box { { - const url = accessMediaUrl(player).get(); + const url = Media.accessMediaUrl(player).get(); // a widget that supports adding multiple icons and allows switching // through them would be pretty nice!! (i'll probably do this later) url &&