{-# LANGUAGE OverloadedStrings #-} module Sound.Player ( appMain ) where import qualified Brick.AttrMap as A import qualified Brick.Main as M import Brick.Types (Widget, EventM, Next, Name(Name), handleEvent) import Brick.Widgets.Core ((<+>), str, vBox) import qualified Brick.Widgets.Border as B import qualified Brick.Widgets.List as L import qualified Brick.Widgets.ProgressBar as P import Brick.Util (on) import Control.Concurrent (Chan, ThreadId, forkIO, killThread, newChan, writeChan, threadDelay) import Control.Exception (SomeException, catch) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Default (def) import Data.List (isPrefixOf, stripPrefix) import Data.Maybe (fromMaybe) import qualified Data.Vector as Vec import qualified Graphics.Vty as V import Lens.Micro ((^.)) import System.Directory (doesDirectoryExist, getDirectoryContents) import System.Environment (getEnv) import System.FilePath (()) import System.Process (ProcessHandle) import Sound.Player.AudioInfo (SongInfo(SongInfo), fetchSongInfo) import qualified Sound.Player.AudioPlay as AP (play, pause, resume, stop) import Sound.Player.Types (Song(Song, songStatus), PlayerApp(PlayerApp, songsList, playerStatus, playback), Playback(Playback, playhead), Status(Play, Pause, Stop), PlayerEvent(VtyEvent, PlayheadAdvance)) import Sound.Player.Widgets (songWidget, playbackProgressBar) -- | Draws application UI. drawUI :: PlayerApp -> [Widget] drawUI (PlayerApp l _ _ mPlayback) = [ui] where label = str "Item " <+> cur <+> str " of " <+> total cur = case l ^. L.listSelectedL of Nothing -> str "-" Just i -> str (show (i + 1)) total = str $ show $ Vec.length $ l ^. L.listElementsL box = B.borderWithLabel label $ L.renderList l (const songWidget) ui = vBox [ box , playbackProgressBar mPlayback l , str $ "Press enter to play/stop, spacebar to pause/resume, " ++ "left/right to play prev/next song, " ++ "q to exit." ] -- Updates the selected song status and the song list in the app state. updateAppStatus :: PlayerApp -> Status -> Int -> PlayerApp updateAppStatus app@(PlayerApp l _ _ _) status pos = app { songsList = L.listReplace songs' mPos l, playerStatus = status } where songs = L.listElements l mPos = l ^. L.listSelectedL song = songs Vec.! pos songs' = songs Vec.// [(pos, song { songStatus = status })] -- | App events handler. appEvent :: PlayerApp -> PlayerEvent -> EventM (Next PlayerApp) appEvent app@(PlayerApp l status _ mPlayback) e = case e of -- press enter to play selected song, stop current song if playing VtyEvent (V.EvKey V.KEnter []) -> M.continue =<< stopAndPlaySelected app -- press spacebar to play/pause VtyEvent (V.EvKey (V.KChar ' ') []) -> case status of Play -> -- pause playing song case mPlayback of Nothing -> M.continue app Just (Playback playPos playProc _ _ _) -> do liftIO $ AP.pause playProc M.continue $ updateAppStatus app Pause playPos Pause -> -- resume playing song case mPlayback of Nothing -> M.continue app Just (Playback playPos playProc _ _ _) -> do liftIO $ AP.resume playProc M.continue $ updateAppStatus app Play playPos Stop -> -- play selected song M.continue =<< play (l ^. L.listSelectedL) app -- press left to play previous song VtyEvent (V.EvKey V.KLeft []) -> M.continue =<< stopAndPlayDelta (-1) app -- press right to play next song VtyEvent (V.EvKey V.KRight []) -> M.continue =<< stopAndPlayDelta 1 app -- press q to quit VtyEvent (V.EvKey (V.KChar 'q') []) -> M.halt =<< stop app -- any other event VtyEvent ev -> do l' <- handleEvent ev l M.continue app { songsList = l' } -- playhead advance event PlayheadAdvance -> M.continue =<< case status of Play -> case mPlayback of Nothing -> return app Just pb@(Playback _ _ ph _ _) -> if ph > 0 then -- advance playhead return app { playback = Just pb { playhead = ph - 1.0 } } else stopAndPlayDelta 1 app _ -> return app -- | Forks a thread that will trigger a 'Types.PlayheadAdvance' event every -- second. playheadAdvanceLoop :: Chan PlayerEvent -> IO ThreadId playheadAdvanceLoop chan = forkIO loop where loop = do threadDelay 1000000 writeChan chan PlayheadAdvance loop -- | Stops the song that is currently playing and kills the playback thread. stop :: (MonadIO m) => PlayerApp -> m PlayerApp stop app@(PlayerApp _ _ _ Nothing) = return app stop app@(PlayerApp _ _ _ (Just pb@(Playback playPos _ _ _ _))) = do liftIO $ stopPlayingSong pb return (updateAppStatus app Stop playPos) { playback = Nothing } where stopPlayingSong (Playback _ playProc _ _ threadId) = do AP.stop playProc killThread threadId -- | Fetches song info, plays it, and starts a thread to advance the playhead. play :: (MonadIO m) => Maybe Int -> PlayerApp -> m PlayerApp play Nothing app = return app play (Just _) app@(PlayerApp _ _ _ (Just _)) = return app play (Just pos) app@(PlayerApp l _ chan _) = do (proc, duration, tId) <- liftIO $ playSong song return (updateAppStatus app Play pos) { playback = Just (Playback pos proc duration duration tId) } where songs = L.listElements l song = songs Vec.! pos failSongInfo :: SomeException -> IO SongInfo failSongInfo _ = return $ SongInfo (-1) playSong :: Song -> IO (ProcessHandle, Double, ThreadId) playSong (Song _ path _) = do musicDir <- defaultMusicDirectory (SongInfo duration) <- catch (fetchSongInfo $ musicDir path) failSongInfo proc <- AP.play $ musicDir path tId <- playheadAdvanceLoop chan return (proc, duration, tId) -- Stops current song and play selected song. stopAndPlaySelected :: (MonadIO m) => PlayerApp -> m PlayerApp stopAndPlaySelected app = stop app >>= play mPos where mPos = songsList app ^. L.listSelectedL -- Stops current song and play current song pos + delta. stopAndPlayDelta :: (MonadIO m) => Int -> PlayerApp -> m PlayerApp stopAndPlayDelta _ app@(PlayerApp _ _ _ Nothing) = return app stopAndPlayDelta delta app@(PlayerApp l _ _ (Just (Playback playPos _ _ _ _))) = stop app >>= play (Just pos) where pos = (playPos + delta) `mod` Vec.length (L.listElements l) -- | Returns the initial state of the application. initialState :: IO PlayerApp initialState = do chan <- newChan paths <- listMusicDirectory let songs = map (\p -> Song Nothing p Stop) paths listWidget = L.list (Name "list") (Vec.fromList songs) 1 return $ PlayerApp listWidget Stop chan Nothing theMap :: A.AttrMap theMap = A.attrMap V.defAttr [ (L.listAttr, V.white `on` V.blue) , (L.listSelectedAttr, V.blue `on` V.white) , (P.progressCompleteAttr, V.blue `on` V.white) ] theApp :: M.App PlayerApp PlayerEvent theApp = M.App { M.appDraw = drawUI , M.appChooseCursor = M.showFirstCursor , M.appHandleEvent = appEvent , M.appStartEvent = return , M.appAttrMap = const theMap , M.appLiftVtyEvent = VtyEvent } -- TODO: return only audio files -- | Returns the list of files in the default music directory. listMusicDirectory :: IO [FilePath] listMusicDirectory = do musicDir <- defaultMusicDirectory map (stripMusicDirectory musicDir) <$> listMusicDirectoryRic [musicDir] where listMusicDirectoryRic [] = return [] listMusicDirectoryRic (p:ps) = do isDirectory <- doesDirectoryExist p if isDirectory then do files <- map (p ) . filter visible <$> getDirectoryContents p listMusicDirectoryRic (files ++ ps) else do files <- listMusicDirectoryRic ps return $ p:files visible = not . isPrefixOf "." stripMusicDirectory musicDir = fromMaybe musicDir . stripPrefix musicDir -- | The default music directory is @$HOME/Music@. defaultMusicDirectory :: IO FilePath defaultMusicDirectory = ( "Music/") <$> getEnv "HOME" appMain :: IO PlayerApp appMain = do playerApp@(PlayerApp _ _ chan _) <- initialState M.customMain (V.mkVty def) chan theApp playerApp