{-# 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 -- (). ----------------------------------------------------------------------------- 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