packages feed

taffybar-7.2.1: src/System/Taffybar/Information/WirePlumber.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ForeignFunctionInterface #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

-----------------------------------------------------------------------------

-----------------------------------------------------------------------------

-- |
-- Module      : System.Taffybar.Information.WirePlumber
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- WirePlumber/PipeWire audio information using libwireplumber through
-- GObject introspection.
--
-- This module keeps a WirePlumber core and object manager open, reads the
-- current node state from cached PipeWire params, and refreshes when
-- WirePlumber emits object or param change signals.
module System.Taffybar.Information.WirePlumber
  ( WirePlumberInfo (..),
    NodeType (..),
    getWirePlumberInfo,
    getWirePlumberInfoChan,
    getWirePlumberInfoState,
    getWirePlumberInfoChanAndVar,
    getWirePlumberInfoChanFor,
    getWirePlumberInfoStateFor,
    toggleWirePlumberMute,
    adjustWirePlumberVolume,
    setWirePlumberVolume,
  )
where

import Control.Concurrent (forkOS, runInBoundThread)
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, finally, try)
import Control.Monad (forM, forM_, unless, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.STM (atomically)
import Data.Aeson (decodeStrict)
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import qualified Data.ByteString.Char8 as B
import Data.Char (isDigit)
import qualified Data.GI.Base.BasicTypes as GI
import qualified Data.GI.Base.GValue as GValue
import qualified Data.GI.Base.GVariant as GVariant
import qualified Data.GI.Base.ManagedPtr as ManagedPtr
import Data.IORef
import Data.Maybe (catMaybes, fromMaybe, listToMaybe, mapMaybe)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Foreign
import Foreign.C.String
import Foreign.C.Types
import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal)
import qualified GI.GLib.Structs.MainContext as MainContext
import qualified GI.GLib.Structs.MainLoop as MainLoop
import qualified GI.GObject.Objects.Object as GObject
import qualified GI.Wp.Enums as Wp
import qualified GI.Wp.Flags as Wp
import qualified GI.Wp.Functions as Wp
import qualified GI.Wp.Interfaces.PipewireObject as PipewireObject
import qualified GI.Wp.Objects.Conf as Conf
import qualified GI.Wp.Objects.Core as Core
import qualified GI.Wp.Objects.Metadata as Metadata
import qualified GI.Wp.Objects.Node as Node
import qualified GI.Wp.Objects.ObjectManager as ObjectManager
import qualified GI.Wp.Structs.Iterator as Iterator
import qualified GI.Wp.Structs.ObjectInterest as ObjectInterest
import qualified GI.Wp.Structs.SpaPod as SpaPod
import System.IO.Unsafe (unsafePerformIO)
import System.Log.Logger (Priority (..))
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Util (logPrintF, runCommand)

-- | Type of audio node.
data NodeType
  = -- | Output device (speakers, headphones)
    Sink
  | -- | Input device (microphone)
    Source
  deriving (Eq, Show)

-- | WirePlumber audio information for a node.
data WirePlumberInfo = WirePlumberInfo
  { -- | Volume level from 0.0 to 1.0 (can exceed 1.0 for amplification)
    wirePlumberVolume :: Double,
    -- | Whether the node is muted
    wirePlumberMuted :: Bool,
    -- | PipeWire node name
    wirePlumberNodeName :: Text
  }
  deriving (Eq, Show)

data WirePlumberSelector
  = DefaultNode NodeType
  | NodeName Text
  | NodeId Text
  deriving (Eq, Show)

data WirePlumberObjectRefs = WirePlumberObjectRefs
  { wirePlumberObjectRef :: GObject.Object,
    wirePlumberNodeRef :: Maybe Node.Node,
    wirePlumberMetadataRef :: Maybe Metadata.Metadata
  }

newtype WirePlumberInfoChanVar (a :: Symbol)
  = WirePlumberInfoChanVar (TChan (Maybe WirePlumberInfo), MVar (Maybe WirePlumberInfo))

wpLogPath :: String
wpLogPath = "System.Taffybar.Information.WirePlumber"

wpLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
wpLogF = logPrintF wpLogPath

foreign import ccall "wp_spa_pod_get_property"
  c_wp_spa_pod_get_property ::
    Ptr SpaPod.SpaPod ->
    Ptr CString ->
    Ptr (Ptr SpaPod.SpaPod) ->
    IO CInt

foreign import ccall "wp_metadata_find"
  c_wp_metadata_find ::
    Ptr Metadata.Metadata ->
    Word32 ->
    CString ->
    Ptr CString ->
    IO CString

wpInitialized :: MVar Bool
wpInitialized = unsafePerformIO $ newMVar False
{-# NOINLINE wpInitialized #-}

initWirePlumber :: IO ()
initWirePlumber =
  modifyMVar_ wpInitialized $ \initialized -> do
    unless initialized $
      Wp.init [Wp.InitFlagsAll]
    pure True

-- | Get a broadcast channel for WirePlumber info for the provided node spec.
--
-- The first call for a given node spec starts a monitoring thread that keeps a
-- WirePlumber connection open and refreshes on PipeWire object/param changes.
-- Subsequent calls return the already created channel.
getWirePlumberInfoChan :: String -> TaffyIO (TChan (Maybe WirePlumberInfo))
getWirePlumberInfoChan nodeSpec =
  case someSymbolVal nodeSpec of
    SomeSymbol (Proxy :: Proxy sym) -> getWirePlumberInfoChanFor @sym

-- | Read the current WirePlumber info state for the provided node spec.
getWirePlumberInfoState :: String -> TaffyIO (Maybe WirePlumberInfo)
getWirePlumberInfoState nodeSpec =
  case someSymbolVal nodeSpec of
    SomeSymbol (Proxy :: Proxy sym) -> getWirePlumberInfoStateFor @sym

-- | Get both the broadcast channel and current state MVar for WirePlumber info.
getWirePlumberInfoChanAndVar :: String -> TaffyIO (TChan (Maybe WirePlumberInfo), MVar (Maybe WirePlumberInfo))
getWirePlumberInfoChanAndVar nodeSpec =
  case someSymbolVal nodeSpec of
    SomeSymbol (Proxy :: Proxy sym) -> do
      WirePlumberInfoChanVar pair <- getWirePlumberInfoChanVarFor @sym
      pure pair

-- | Get a broadcast channel for WirePlumber info for a node spec given as a
-- type-level string.
getWirePlumberInfoChanFor :: forall a. (KnownSymbol a) => TaffyIO (TChan (Maybe WirePlumberInfo))
getWirePlumberInfoChanFor = do
  WirePlumberInfoChanVar (chan, _) <- getWirePlumberInfoChanVarFor @a
  pure chan

-- | Read the current WirePlumber info state for a node spec given as a
-- type-level string.
getWirePlumberInfoStateFor :: forall a. (KnownSymbol a) => TaffyIO (Maybe WirePlumberInfo)
getWirePlumberInfoStateFor = do
  WirePlumberInfoChanVar (_, var) <- getWirePlumberInfoChanVarFor @a
  liftIO $ readMVar var

getWirePlumberInfoChanVarFor :: forall a. (KnownSymbol a) => TaffyIO (WirePlumberInfoChanVar a)
getWirePlumberInfoChanVarFor =
  getStateDefault $ do
    let nodeSpec = symbolVal (Proxy @a)
    liftIO $ do
      chan <- newBroadcastTChanIO
      var <- newMVar Nothing
      _ <- forkOS $ monitorWirePlumberInfo nodeSpec chan var
      pure $ WirePlumberInfoChanVar (chan, var)

monitorWirePlumberInfo ::
  String ->
  TChan (Maybe WirePlumberInfo) ->
  MVar (Maybe WirePlumberInfo) ->
  IO ()
monitorWirePlumberInfo nodeSpec chan var = do
  result <- try $ do
    context <- MainContext.mainContextNew
    MainContext.mainContextPushThreadDefault (Just context)
    ( do
        initWirePlumber
        loop <- MainLoop.mainLoopNew (Just context) False
        core <- Core.coreNew (Just context) (Nothing :: Maybe Conf.Conf) Nothing
        manager <- buildObjectManager nodeSpec
        refreshLock <- newMVar ()
        connectedSignalRefs <- newIORef []
        connectedObjectRefs <- newIORef []

        let writeInfo info = do
              oldInfo <- swapMVar var info
              when (oldInfo /= info) $
                atomically $
                  writeTChan chan info

            refresh = withMVar refreshLock $ \_ ->
              readWirePlumberInfoFromRefs connectedObjectRefs nodeSpec >>= writeInfo

            rememberSignal ref = modifyIORef' connectedSignalRefs (ref :)

            rememberObject object maybeNode maybeMetadata =
              modifyIORef'
                connectedObjectRefs
                (WirePlumberObjectRefs object maybeNode maybeMetadata :)

            connectNode object = do
              maybeNode <- ManagedPtr.castTo Node.Node object
              forM_ maybeNode $ \node -> do
                mediaClass <- PipewireObject.pipewireObjectGetProperty node "media.class"
                when (mediaClass `elem` [Just "Audio/Sink", Just "Audio/Source"]) $ do
                  handler <-
                    PipewireObject.onPipewireObjectParamsChanged node $ \param ->
                      when (param == "Props") refresh
                  rememberSignal handler
              pure maybeNode

            connectMetadata object = do
              maybeMetadata <- ManagedPtr.castTo Metadata.Metadata object
              forM_ maybeMetadata $ \metadata -> do
                handler <-
                  Metadata.onMetadataChanged metadata $ \subject maybeKey _type _value ->
                    when
                      ( subject == 0
                          && maybe False (`elem` defaultMetadataKeys) maybeKey
                      )
                      refresh
                rememberSignal handler
              pure maybeMetadata

            connectObject object = do
              maybeNode <- connectNode object
              maybeMetadata <- connectMetadata object
              rememberObject object maybeNode maybeMetadata

        _ <- ObjectManager.onObjectManagerInstalled manager $ do
          objects <- getManagedObjects manager
          mapM_ connectObject objects
          refresh

        _ <- ObjectManager.onObjectManagerObjectAdded manager $ \object -> do
          connectObject object
          refresh

        _ <- ObjectManager.onObjectManagerObjectRemoved manager $ const refresh
        _ <- ObjectManager.onObjectManagerObjectsChanged manager refresh

        Core.coreInstallObjectManager core manager
        connected <- Core.coreConnect core
        unless connected $ fail "failed to connect WirePlumber core"
        MainLoop.mainLoopRun loop
        ManagedPtr.touchManagedPtr core
        ManagedPtr.touchManagedPtr manager
        ManagedPtr.touchManagedPtr loop
      )
      `finally` MainContext.mainContextPopThreadDefault (Just context)
  case result of
    Right () -> pure ()
    Left (e :: SomeException) -> do
      wpLogF WARNING "WirePlumber monitor failed: %s" e
      _ <- swapMVar var Nothing
      atomically $ writeTChan chan Nothing

buildObjectManager :: String -> IO ObjectManager.ObjectManager
buildObjectManager nodeSpec = do
  manager <- ObjectManager.objectManagerNew
  nodeType <- GI.glibType @Node.Node
  metadataType <- GI.glibType @Metadata.Metadata

  nodeInterest <- ObjectInterest.objectInterestNewType nodeType
  forM_ (selectorMediaClass $ parseNodeSpec nodeSpec) $ \mediaClass -> do
    mediaClassVariant <- GVariant.gvariantFromText mediaClass
    ObjectInterest.objectInterestAddConstraint
      nodeInterest
      Wp.ConstraintTypePwProperty
      "media.class"
      Wp.ConstraintVerbEquals
      (Just mediaClassVariant)
  ObjectManager.objectManagerAddInterestFull manager nodeInterest
  ObjectManager.objectManagerRequestObjectFeatures
    manager
    nodeType
    (fromIntegral $ fromEnum Wp.ProxyFeaturesPipewireObjectFeaturesAll)

  metadataInterest <- ObjectInterest.objectInterestNewType metadataType
  ObjectManager.objectManagerAddInterestFull manager metadataInterest
  ObjectManager.objectManagerRequestObjectFeatures
    manager
    metadataType
    (fromIntegral $ fromEnum Wp.ProxyFeaturesPipewireObjectFeaturesMinimal)

  pure manager

-- | Query volume and mute state for the provided node spec once.
getWirePlumberInfo :: String -> IO (Maybe WirePlumberInfo)
getWirePlumberInfo nodeSpec = runInBoundThread $ do
  result <- try $ do
    context <- MainContext.mainContextNew
    MainContext.mainContextPushThreadDefault (Just context)
    ( do
        initWirePlumber
        loop <- MainLoop.mainLoopNew (Just context) False
        core <- Core.coreNew (Just context) (Nothing :: Maybe Conf.Conf) Nothing
        manager <- buildObjectManager nodeSpec
        var <- newMVar Nothing

        _ <- ObjectManager.onObjectManagerInstalled manager $ do
          objects <- getManagedObjects manager
          refs <- mapM buildWirePlumberObjectRefs objects
          info <- readWirePlumberInfoFromObjectRefs refs nodeSpec
          _ <- swapMVar var info
          MainLoop.mainLoopQuit loop

        _ <- Core.coreTimeoutAdd core 5000 $ do
          MainLoop.mainLoopQuit loop
          pure False

        Core.coreInstallObjectManager core manager
        connected <- Core.coreConnect core
        unless connected $ fail "failed to connect WirePlumber core"
        MainLoop.mainLoopRun loop
        info <- readMVar var
        ManagedPtr.touchManagedPtr core
        ManagedPtr.touchManagedPtr manager
        ManagedPtr.touchManagedPtr loop
        pure info
      )
      `finally` MainContext.mainContextPopThreadDefault (Just context)
  case result of
    Right info -> pure info
    Left (e :: SomeException) -> do
      wpLogF WARNING "WirePlumber query failed: %s" e
      pure Nothing

readWirePlumberInfoFromRefs ::
  IORef [WirePlumberObjectRefs] ->
  String ->
  IO (Maybe WirePlumberInfo)
readWirePlumberInfoFromRefs refs nodeSpec =
  readIORef refs >>= flip readWirePlumberInfoFromObjectRefs nodeSpec

readWirePlumberInfoFromObjectRefs ::
  [WirePlumberObjectRefs] ->
  String ->
  IO (Maybe WirePlumberInfo)
readWirePlumberInfoFromObjectRefs refs nodeSpec =
  ( do
      let metadata = mapMaybe wirePlumberMetadataRef refs
          nodes = mapMaybe wirePlumberNodeRef refs
      selectedNode <- selectNode metadata nodes (parseNodeSpec nodeSpec)
      maybe (pure Nothing) (fmap Just . readNodeInfo) selectedNode
  )
    `finally` mapM_ touchWirePlumberObjectRefs refs

touchWirePlumberObjectRefs :: WirePlumberObjectRefs -> IO ()
touchWirePlumberObjectRefs refs = do
  ManagedPtr.touchManagedPtr $ wirePlumberObjectRef refs
  mapM_ ManagedPtr.touchManagedPtr $ wirePlumberNodeRef refs
  mapM_ ManagedPtr.touchManagedPtr $ wirePlumberMetadataRef refs

buildWirePlumberObjectRefs :: GObject.Object -> IO WirePlumberObjectRefs
buildWirePlumberObjectRefs object =
  WirePlumberObjectRefs object
    <$> objectToNode object
    <*> objectToMetadata object

getManagedObjects :: ObjectManager.ObjectManager -> IO [GObject.Object]
getManagedObjects manager = do
  iterator <- ObjectManager.objectManagerNewIterator manager
  catMaybes <$> iteratorValues @(Maybe GObject.Object) iterator

objectToNode :: GObject.Object -> IO (Maybe Node.Node)
objectToNode = ManagedPtr.castTo Node.Node

objectToMetadata :: GObject.Object -> IO (Maybe Metadata.Metadata)
objectToMetadata = ManagedPtr.castTo Metadata.Metadata

selectNode ::
  [Metadata.Metadata] ->
  [Node.Node] ->
  WirePlumberSelector ->
  IO (Maybe Node.Node)
selectNode metadata nodes selector =
  case selector of
    DefaultNode nodeType -> do
      maybeDefaultName <- readDefaultNodeName metadata nodeType
      case maybeDefaultName of
        Just defaultName -> findM (nodeNameMatches defaultName) =<< filterNodesByType nodeType nodes
        Nothing -> listToMaybe <$> filterNodesByType nodeType nodes
    NodeName name -> findM (nodeNameMatches name) nodes
    NodeId nodeId -> findM (nodeIdMatches nodeId) nodes

filterNodesByType :: NodeType -> [Node.Node] -> IO [Node.Node]
filterNodesByType nodeType =
  filterMIO $ \node ->
    (== Just (nodeTypeMediaClass nodeType))
      <$> PipewireObject.pipewireObjectGetProperty node "media.class"

nodeNameMatches :: Text -> Node.Node -> IO Bool
nodeNameMatches expected node =
  (== Just expected) <$> PipewireObject.pipewireObjectGetProperty node "node.name"

nodeIdMatches :: Text -> Node.Node -> IO Bool
nodeIdMatches expected node =
  (== Just expected) <$> PipewireObject.pipewireObjectGetProperty node "object.id"

readDefaultNodeName :: [Metadata.Metadata] -> NodeType -> IO (Maybe Text)
readDefaultNodeName metadata nodeType = do
  names <-
    forM metadata $ \m -> do
      result <- metadataFindNoFree m 0 (defaultMetadataKey nodeType)
      pure $ result >>= parseDefaultNodeName . fst
  pure $ listToMaybe $ catMaybes names

readNodeInfo :: Node.Node -> IO WirePlumberInfo
readNodeInfo node = do
  nodeName <- fromMaybe "" <$> PipewireObject.pipewireObjectGetProperty node "node.name"
  props <- readNodeProps node
  maybeRawVolume <-
    maybe (pure Nothing) podArrayChildNumber $
      lookup "channelVolumes" props
  maybeMuted <-
    maybe (pure Nothing) podBool $
      lookup "mute" props
  let volume = pipeWireRawVolumeToLinear $ fromMaybe 0 maybeRawVolume
      muted = fromMaybe False maybeMuted
  pure $
    WirePlumberInfo
      { wirePlumberVolume = volume,
        wirePlumberMuted = muted,
        wirePlumberNodeName = nodeName
      }

readNodeProps :: Node.Node -> IO [(Text, SpaPod.SpaPod)]
readNodeProps node = do
  maybeParams <- PipewireObject.pipewireObjectEnumParamsSync node "Props" Nothing
  case maybeParams of
    Nothing -> pure []
    Just params -> do
      pods <- catMaybes <$> iteratorValues @(Maybe SpaPod.SpaPod) params
      fmap concat $
        forM pods $ \pod -> do
          iterator <- SpaPod.spaPodNewIterator pod
          props <- catMaybes <$> iteratorValues @(Maybe SpaPod.SpaPod) iterator
          fmap catMaybes $
            forM props $ \propPod -> do
              (ok, key, value) <- spaPodGetPropertyNoFree propPod
              pure $ if ok then Just (key, value) else Nothing

spaPodGetPropertyNoFree :: SpaPod.SpaPod -> IO (Bool, Text, SpaPod.SpaPod)
spaPodGetPropertyNoFree pod =
  ManagedPtr.withManagedPtr pod $ \podPtr ->
    alloca $ \keyPtr ->
      alloca $ \valuePtr -> do
        result <- c_wp_spa_pod_get_property podPtr keyPtr valuePtr
        keyPtrValue <- peek keyPtr
        valuePtrValue <- peek valuePtr
        key <-
          if keyPtrValue == nullPtr
            then pure ""
            else T.pack <$> peekCString keyPtrValue
        value <- ManagedPtr.wrapBoxed SpaPod.SpaPod valuePtrValue
        pure (result /= 0, key, value)

metadataFindNoFree :: Metadata.Metadata -> Word32 -> Text -> IO (Maybe (Text, Text))
metadataFindNoFree metadata subject key =
  ManagedPtr.withManagedPtr metadata $ \metadataPtr ->
    B.useAsCString (T.encodeUtf8 key) $ \keyPtr ->
      alloca $ \typePtr -> do
        valuePtr <- c_wp_metadata_find metadataPtr subject keyPtr typePtr
        if valuePtr == nullPtr
          then pure Nothing
          else do
            typePtrValue <- peek typePtr
            value <- T.pack <$> peekCString valuePtr
            type_ <-
              if typePtrValue == nullPtr
                then pure ""
                else T.pack <$> peekCString typePtrValue
            pure $ Just (value, type_)

podArrayChildNumber :: SpaPod.SpaPod -> IO (Maybe Double)
podArrayChildNumber pod =
  do
    isArray <- SpaPod.spaPodIsArray pod
    if isArray
      then SpaPod.spaPodGetArrayChild pod >>= podNumber
      else podNumber pod

podNumber :: SpaPod.SpaPod -> IO (Maybe Double)
podNumber pod = do
  isFloat <- SpaPod.spaPodIsFloat pod
  isDouble <- SpaPod.spaPodIsDouble pod
  if isFloat
    then do
      (ok, value) <- SpaPod.spaPodGetFloat pod
      pure $ if ok then Just (realToFrac value) else Nothing
    else
      if isDouble
        then do
          (ok, value) <- SpaPod.spaPodGetDouble pod
          pure $ if ok then Just value else Nothing
        else pure Nothing

podBool :: SpaPod.SpaPod -> IO (Maybe Bool)
podBool pod = do
  isBool <- SpaPod.spaPodIsBoolean pod
  if isBool
    then do
      (ok, value) <- SpaPod.spaPodGetBoolean pod
      pure $ if ok then Just value else Nothing
    else pure Nothing

parseDefaultNodeName :: Text -> Maybe Text
parseDefaultNodeName value =
  decodeStrict (T.encodeUtf8 value) >>= parseMaybe parseName
  where
    parseName = withObject "WirePlumber default audio target" (.: "name")

pipeWireRawVolumeToLinear :: Double -> Double
pipeWireRawVolumeToLinear rawVolume = rawVolume ** (1 / 3 :: Double)

parseNodeSpec :: String -> WirePlumberSelector
parseNodeSpec "" = DefaultNode Sink
parseNodeSpec "@DEFAULT_AUDIO_SINK@" = DefaultNode Sink
parseNodeSpec "@DEFAULT_SINK@" = DefaultNode Sink
parseNodeSpec "@DEFAULT_AUDIO_SOURCE@" = DefaultNode Source
parseNodeSpec "@DEFAULT_SOURCE@" = DefaultNode Source
parseNodeSpec spec
  | all isDigit spec = NodeId $ T.pack spec
  | otherwise = NodeName $ T.pack spec

selectorMediaClass :: WirePlumberSelector -> Maybe Text
selectorMediaClass (DefaultNode nodeType) = Just $ nodeTypeMediaClass nodeType
selectorMediaClass _ = Nothing

nodeTypeMediaClass :: NodeType -> Text
nodeTypeMediaClass Sink = "Audio/Sink"
nodeTypeMediaClass Source = "Audio/Source"

defaultMetadataKey :: NodeType -> Text
defaultMetadataKey Sink = "default.audio.sink"
defaultMetadataKey Source = "default.audio.source"

defaultMetadataKeys :: [Text]
defaultMetadataKeys =
  [ "default.audio.sink",
    "default.audio.source",
    "default.configured.audio.sink",
    "default.configured.audio.source"
  ]

findM :: (a -> IO Bool) -> [a] -> IO (Maybe a)
findM _ [] = pure Nothing
findM predicate (x : xs) = do
  matches <- predicate x
  if matches then pure $ Just x else findM predicate xs

filterMIO :: (a -> IO Bool) -> [a] -> IO [a]
filterMIO predicate = fmap (map fst . filter snd) . mapM (\x -> (x,) <$> predicate x)

iteratorValues :: (GValue.IsGValue a) => Iterator.Iterator -> IO [a]
iteratorValues iterator = do
  (hasNext, value) <- Iterator.iteratorNext iterator
  if hasNext
    then do
      item <- GValue.fromGValue value
      rest <- iteratorValues iterator
      pure (item : rest)
    else pure []

-- | Toggle mute for the provided node spec. Returns True on success.
toggleWirePlumberMute :: String -> IO Bool
toggleWirePlumberMute nodeSpec = do
  let node = if null nodeSpec then "@DEFAULT_AUDIO_SINK@" else nodeSpec
  result <- runCommand "wpctl" ["set-mute", node, "toggle"]
  case result of
    Left err -> do
      wpLogF WARNING "wpctl set-mute toggle failed: %s" err
      return False
    Right _ -> return True

-- | Adjust volume by the provided percentage delta. Returns True on success.
--
-- Positive values increase volume, negative values decrease it.
-- The delta is clamped to prevent going below 0%.
adjustWirePlumberVolume :: String -> Int -> IO Bool
adjustWirePlumberVolume nodeSpec deltaPercent = do
  let node = if null nodeSpec then "@DEFAULT_AUDIO_SINK@" else nodeSpec
      sign = if deltaPercent >= 0 then "+" else "-"
      absVal = abs deltaPercent
      arg = show absVal ++ "%" ++ sign
  result <- runCommand "wpctl" ["set-volume", node, arg, "--limit", "1.5"]
  case result of
    Left err -> do
      wpLogF WARNING "wpctl set-volume failed: %s" err
      return False
    Right _ -> return True

-- | Set volume to an absolute percentage value. Returns True on success.
setWirePlumberVolume :: String -> Int -> IO Bool
setWirePlumberVolume nodeSpec percent = do
  let node = if null nodeSpec then "@DEFAULT_AUDIO_SINK@" else nodeSpec
      arg = show (max 0 percent) ++ "%"
  result <- runCommand "wpctl" ["set-volume", node, arg, "--limit", "1.5"]
  case result of
    Left err -> do
      wpLogF WARNING "wpctl set-volume failed: %s" err
      return False
    Right _ -> return True