packages feed

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

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# 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 the @wpctl@ command-line tool.
--
-- This module provides access to audio volume and mute state for the default
-- audio sink or source via WirePlumber's command-line interface.
module System.Taffybar.Information.WirePlumber
  ( WirePlumberInfo (..),
    NodeType (..),
    getWirePlumberInfo,
    getWirePlumberInfoChan,
    getWirePlumberInfoState,
    getWirePlumberInfoChanFor,
    getWirePlumberInfoStateFor,
    toggleWirePlumberMute,
    adjustWirePlumberVolume,
    setWirePlumberVolume,
  )
where

import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, try)
import Control.Monad (void)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.STM (atomically)
import Data.List (isInfixOf)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal)
import System.Log.Logger (Priority (..))
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Information.Wakeup (taffyForeverWithDelay)
import System.Taffybar.Util (logPrintF, runCommand)
import Text.Read (readMaybe)

-- | 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,
    -- | Name of the node (e.g., "@DEFAULT_AUDIO_SINK@")
    wirePlumberNodeName :: Text
  }
  deriving (Eq, Show)

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

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

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

-- | Get a broadcast channel for WirePlumber info for the provided node spec.
--
-- The first call for a given node spec will start a monitoring thread that
-- polls @wpctl@ periodically and broadcasts updates.
-- 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.
--
-- See 'getWirePlumberInfoChan' for monitoring behavior.
getWirePlumberInfoState :: String -> TaffyIO (Maybe WirePlumberInfo)
getWirePlumberInfoState nodeSpec =
  case someSymbolVal nodeSpec of
    SomeSymbol (Proxy :: Proxy sym) -> getWirePlumberInfoStateFor @sym

-- | 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)
    chan <- liftIO newBroadcastTChanIO
    var <- liftIO $ newMVar Nothing
    liftIO $ refreshWirePlumberInfo nodeSpec chan var
    let pollIntervalSeconds :: Double
        pollIntervalSeconds = fromIntegral pollIntervalMicros / 1000000
    void $
      taffyForeverWithDelay pollIntervalSeconds $
        liftIO $
          refreshWirePlumberInfo nodeSpec chan var
    pure $ WirePlumberInfoChanVar (chan, var)

-- | Polling interval in microseconds (1 second).
pollIntervalMicros :: Int
pollIntervalMicros = 1_000_000

refreshWirePlumberInfo ::
  String ->
  TChan (Maybe WirePlumberInfo) ->
  MVar (Maybe WirePlumberInfo) ->
  IO ()
refreshWirePlumberInfo nodeSpec chan var = do
  result <- try $ getWirePlumberInfo nodeSpec
  let info = case result of
        Left (_ :: SomeException) -> Nothing
        Right i -> i
  _ <- swapMVar var info
  atomically $ writeTChan chan info

-- | Query volume and mute state for the provided node spec.
--
-- The node spec can be:
-- * "@DEFAULT_AUDIO_SINK@" or "" for the default output
-- * "@DEFAULT_AUDIO_SOURCE@" for the default input
-- * A numeric node ID
--
-- Returns 'Nothing' if wpctl is not available or fails.
getWirePlumberInfo :: String -> IO (Maybe WirePlumberInfo)
getWirePlumberInfo nodeSpec = do
  let node = if null nodeSpec then "@DEFAULT_AUDIO_SINK@" else nodeSpec
  result <- runCommand "wpctl" ["get-volume", node]
  case result of
    Left err -> do
      wpLogF WARNING "wpctl get-volume failed: %s" err
      return Nothing
    Right output -> return $ parseWpctlOutput node output

-- | Parse the output of @wpctl get-volume@.
--
-- Expected formats:
-- * "Volume: 0.50"
-- * "Volume: 0.50 [MUTED]"
parseWpctlOutput :: String -> String -> Maybe WirePlumberInfo
parseWpctlOutput nodeSpec output = do
  let trimmed = dropWhile (== ' ') output
  -- Check if it starts with "Volume:"
  volumeStr <-
    if "Volume:" `isInfixOf` trimmed
      then Just $ drop 7 $ dropWhile (/= ':') trimmed
      else Nothing
  -- Parse the volume value and mute state
  let isMuted = "[MUTED]" `isInfixOf` volumeStr
      cleanVolStr = takeWhile (\c -> c /= '[' && c /= '\n') $ dropWhile (== ' ') volumeStr
  volume <- readMaybe cleanVolStr
  return
    WirePlumberInfo
      { wirePlumberVolume = volume,
        wirePlumberMuted = isMuted,
        wirePlumberNodeName = T.pack nodeSpec
      }

-- | 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