taffybar-4.0.0: src/System/Taffybar/Widget/MPRIS2.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE FlexibleContexts #-}
-----------------------------------------------------------------------------
-- |
-- Module : System.Taffybar.Widget.MPRIS2
-- Copyright : (c) Ivan A. Malison
-- License : BSD3-style (see LICENSE)
--
-- Maintainer : Ivan A. Malison
-- Stability : unstable
-- Portability : unportable
--
-- This is a "Now Playing" widget that listens for MPRIS2 events on DBus. You
-- can find the MPRIS2 specification here at
-- (<https://specifications.freedesktop.org/mpris-spec/latest/>).
-----------------------------------------------------------------------------
module System.Taffybar.Widget.MPRIS2 where
import Control.Arrow
import qualified Control.Concurrent.MVar as MV
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Class
import Control.Monad.Trans.Except
import Control.Monad.Trans.Reader
import DBus
import DBus.Client
import qualified DBus.TH as DBus
import Data.Default (Default(..))
import Data.GI.Base.Overloading (IsDescendantOf)
import Data.Int
import Data.List
import qualified Data.Map as M
import qualified Data.Text as T
import qualified GI.GLib as G
import GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk
import System.Environment.XDG.DesktopEntry
import System.Log.Logger
import System.Taffybar.Context
import qualified System.Taffybar.DBus.Client.MPRIS2 as MPRIS2DBus
import System.Taffybar.Information.MPRIS2
import System.Taffybar.Util
import System.Taffybar.Widget.Generic.AutoSizeImage
import System.Taffybar.Widget.Util
import System.Taffybar.WindowIcon
import Text.Printf
mprisLog :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
mprisLog = logPrintF "System.Taffybar.Widget.MPRIS2"
-- | A type representing a function that produces an IO action that adds the
-- provided widget to some container.
type WidgetAdder a m =
(IsDescendantOf Gtk.Widget a
, MonadIO m
, Gtk.GObject a
) => a -> m ()
-- | The type of a customization function that is used to update a widget with
-- the provided now playing info. The type a should be the internal state used
-- for the widget (typically just references to the child widgets that may need
-- to be updated ). When the provided value is nothing, it means that the widget
-- does not exist yet and it should be instantiated. When the provided
-- NowPlaying value is Nothing, the dbus client is no longer, and typically the
-- widget should be hidden.
type UpdateMPRIS2PlayerWidget a =
(forall w. WidgetAdder w IO) -> Maybe a -> Maybe NowPlaying -> TaffyIO a
-- | Configuration for an MPRIS2 Widget
data MPRIS2Config a =
MPRIS2Config
{
-- | A function that will be used to wrap the outer MPRIS2 grid widget
mprisWidgetWrapper :: Gtk.Widget -> IO Gtk.Widget
-- | This function will be called to instantiate and update the player widgets
-- of each dbus player client. See the docstring for `UpdateMPRIS2PlayerWidget`
-- for more details.
, updatePlayerWidget :: UpdateMPRIS2PlayerWidget a
}
defaultMPRIS2Config :: MPRIS2Config MPRIS2PlayerWidget
defaultMPRIS2Config =
MPRIS2Config
{ mprisWidgetWrapper = return
, updatePlayerWidget = simplePlayerWidget def
}
data MPRIS2PlayerWidget = MPRIS2PlayerWidget
{ playerLabel :: Gtk.Label
, playerWidget :: Gtk.Widget
}
data SimpleMPRIS2PlayerConfig = SimpleMPRIS2PlayerConfig
{ setNowPlayingLabel :: NowPlaying -> IO T.Text
, showPlayerWidgetFn :: NowPlaying -> IO Bool
}
defaultPlayerConfig :: SimpleMPRIS2PlayerConfig
defaultPlayerConfig = SimpleMPRIS2PlayerConfig
{ setNowPlayingLabel = playingText 20 30
, showPlayerWidgetFn =
\NowPlaying { npStatus = status } -> return $ status /= "Stopped"
}
instance Default SimpleMPRIS2PlayerConfig where
def = defaultPlayerConfig
makeExcept :: String -> (a -> IO (Maybe b)) -> a -> ExceptT String IO b
makeExcept errorString actionBuilder =
ExceptT . fmap (maybeToEither errorString) . actionBuilder
loadIconAtSize ::
Client -> BusName -> Int32 -> IO Gdk.Pixbuf
loadIconAtSize client busName size =
let
failure err =
mprisLog WARNING "Failed to load default image: %s" err >>
pixBufFromColor size 0
loadDefault =
loadIcon size "play.svg" >>= either failure return
logErrorAndLoadDefault err =
mprisLog WARNING "Failed to get MPRIS icon: %s" err >>
mprisLog WARNING "MPRIS failure for: %s" busName >>
loadDefault
chromeSpecialCase l@(Left _) =
if "chrom" `isInfixOf` formatBusName busName
then Right "google-chrome" else l
chromeSpecialCase x = x
in
either logErrorAndLoadDefault return =<<
runExceptT (ExceptT (left show . chromeSpecialCase <$> MPRIS2DBus.getDesktopEntry client busName)
>>= makeExcept "Failed to get desktop entry"
getDirectoryEntryDefault
>>= makeExcept "Failed to get image"
(getImageForDesktopEntry size))
-- | This is the default player widget constructor that is used to build mpris
-- widgets. It provides only an icon and NowPlaying text.
simplePlayerWidget ::
SimpleMPRIS2PlayerConfig -> UpdateMPRIS2PlayerWidget MPRIS2PlayerWidget
simplePlayerWidget _ _
(Just p@MPRIS2PlayerWidget { playerWidget = widget })
Nothing =
lift $ Gtk.widgetHide widget >> return p
simplePlayerWidget c addToParent Nothing
np@(Just NowPlaying { npBusName = busName }) = do
ctx <- ask
client <- asks sessionDBusClient
lift $ do
mprisLog DEBUG "Building widget for %s" busName
image <- autoSizeImageNew (loadIconAtSize client busName) Gtk.OrientationHorizontal
playerBox <- Gtk.gridNew
label <- Gtk.labelNew Nothing
ebox <- Gtk.eventBoxNew
_ <- Gtk.onWidgetButtonPressEvent ebox $
const $ MPRIS2DBus.playPause client busName >> return True
Gtk.containerAdd playerBox image
Gtk.containerAdd playerBox label
Gtk.containerAdd ebox playerBox
vFillCenter playerBox
addToParent ebox
Gtk.widgetSetVexpand playerBox True
Gtk.widgetSetName playerBox $ T.pack $ formatBusName busName
Gtk.widgetShowAll ebox
Gtk.widgetHide ebox
widget <- Gtk.toWidget ebox
let widgetData =
MPRIS2PlayerWidget { playerLabel = label, playerWidget = widget }
flip runReaderT ctx $
simplePlayerWidget c addToParent (Just widgetData) np
simplePlayerWidget config _
(Just w@MPRIS2PlayerWidget
{ playerLabel = label
, playerWidget = widget
}) (Just nowPlaying) = lift $ do
mprisLog DEBUG "Setting state %s" nowPlaying
Gtk.labelSetMarkup label =<< setNowPlayingLabel config nowPlaying
shouldShow <- showPlayerWidgetFn config nowPlaying
if shouldShow
then Gtk.widgetShowAll widget
else Gtk.widgetHide widget
return w
simplePlayerWidget _ _ _ _ =
mprisLog WARNING "widget update called with no widget or %s"
("nowplaying" :: String) >> return undefined
-- | Construct a new MPRIS2 widget using the `simplePlayerWidget` constructor.
mpris2New :: TaffyIO Gtk.Widget
mpris2New = mpris2NewWithConfig defaultMPRIS2Config
-- | Construct a new MPRIS2 widget with the provided configuration.
mpris2NewWithConfig :: MPRIS2Config a -> TaffyIO Gtk.Widget
mpris2NewWithConfig config = ask >>= \ctx -> asks sessionDBusClient >>= \client -> lift $ do
grid <- Gtk.gridNew
outerWidget <- Gtk.toWidget grid >>= mprisWidgetWrapper config
vFillCenter grid
playerWidgetsVar <- MV.newMVar M.empty
let
updateWidget = updatePlayerWidget config
updatePlayerWidgets nowPlayings playerWidgets = do
let
updateWidgetFromNP np@NowPlaying { npBusName = busName } =
(busName,) <$> updateWidget (Gtk.containerAdd grid)
(M.lookup busName playerWidgets) (Just np)
activeBusNames = map npBusName nowPlayings
existingBusNames = M.keys playerWidgets
inactiveBusNames = existingBusNames \\ activeBusNames
callForNoPlayingAvailable busName =
updateWidget (Gtk.containerAdd grid)
(M.lookup busName playerWidgets) Nothing
-- Invoke the widgets with no NowPlaying so they can hide etc.
mapM_ callForNoPlayingAvailable inactiveBusNames
-- Update all the other widgets
updatedWidgets <- M.fromList <$> mapM updateWidgetFromNP nowPlayings
return $ M.union updatedWidgets playerWidgets
updatePlayerWidgetsVar nowPlayings = postGUISync $
MV.modifyMVar_ playerWidgetsVar $ flip runReaderT ctx .
updatePlayerWidgets nowPlayings
setPlayingClass = do
anyVisible <- anyM Gtk.widgetIsVisible =<< Gtk.containerGetChildren grid
if anyVisible
then do
addClassIfMissing "visible-children" outerWidget
removeClassIfPresent "no-visible-children" outerWidget
else do
addClassIfMissing "no-visible-children" outerWidget
removeClassIfPresent "visible-children" outerWidget
doUpdate = do
nowPlayings <- getNowPlayingInfo client
updatePlayerWidgetsVar nowPlayings
setPlayingClass
signalCallback _ _ _ _ = doUpdate
propMatcher = matchAny { matchPath = Just "/org/mpris/MediaPlayer2" }
handleNameOwnerChanged _ name _ _ = do
playerWidgets <- MV.readMVar playerWidgetsVar
busName <- parseBusName name
when (busName `M.member` playerWidgets) doUpdate
_ <- Gtk.onWidgetRealize grid $ do
updateHandler <-
DBus.registerForPropertiesChanged client propMatcher signalCallback
nameHandler <-
DBus.registerForNameOwnerChanged client matchAny handleNameOwnerChanged
doUpdate
void $ Gtk.onWidgetUnrealize grid $
removeMatch client updateHandler >> removeMatch client nameHandler
Gtk.widgetShow grid
setPlayingClass
return outerWidget
-- | Generate now playing text with the artist truncated to a maximum given by
-- the first provided int, and the song title truncated to a maximum given by
-- the second provided int.
playingText :: MonadIO m => Int -> Int -> NowPlaying -> m T.Text
playingText artistMax songMax NowPlaying {npArtists = artists, npTitle = title} =
G.markupEscapeText formattedText (-1)
where truncatedTitle = truncateString songMax title
formattedText = T.pack $ if null artists
then truncatedTitle
else printf
"%s - %s"
(truncateString artistMax $ intercalate "," artists)
truncatedTitle