netease-fm-1.2.2: exe/UI/Player.hs
{-# LANGUAGE RecordWildCards, OverloadedStrings #-}
module UI.Player (
musicPlayer
) where
import qualified Brick.Main as UI
import qualified Brick.Types as UI
import qualified Brick.Widgets.Center as UI
import qualified Brick.Widgets.Core as UI
import qualified Graphics.Vty as UI
import qualified UI.Extra as UI
import Control.Concurrent.Chan (writeChan, newChan)
import Control.Concurrent.STM.TVar
import Control.Monad (void, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Cont (ContT (..))
import Control.Monad.STM (atomically)
import Data.Default.Class
import Data.Foldable (toList)
import Data.List (intercalate)
import Data.Maybe (isJust, fromJust)
import qualified Data.Sequence as S
import Text.Printf (printf)
import System.Random (randomRIO)
import System.Process (callProcess)
import qualified FM.FM as FM
import qualified FM.Song as Song
import qualified FM.Cache as Cache
import qualified FM.NetEase as NetEase
import UI.Menu
import Types
data State = State {
session :: SomeSession
, player :: Player
, cache :: Cache
, source :: MusicSource
, playMode :: PlayMode
, playSequence :: S.Seq Song.Song
, stopped :: Bool
, currentIndex :: Int
, focusedIndex :: Int
, progress :: (Double, Double)
, currentLyrics :: String
, postEvent :: Event -> IO ()
, autoProceed :: Bool
}
data Event = VtyEvent UI.Event
| UserEventFetchMore
| UserEventPending Bool
| UserEventUpdateProgress (Double, Double)
| UserEventUpdateLyrics String
liftCache State {..} m = liftIO $ runCache cache m
liftSession State {..} m = liftIO $ runSession session m
liftPlayer State {..} m = liftIO $ runPlayer player m
fetch :: (MonadIO m) => State -> m [Song.Song]
fetch state@State {..} = case source of
NetEaseFM -> liftSession state NetEase.fetchFM
NetEaseDailyRecommendation -> liftSession state NetEase.fetchRecommend
NetEasePlayLists -> undefined
NetEasePlayList id _ -> liftSession state (NetEase.fetchPlayList id)
LocalCache -> liftSession state Cache.fetchCache
fetchUrl :: (MonadIO m) => State -> Song.Song -> m (Maybe String)
fetchUrl state@State {..} song = if isLocal source
then liftSession state (Cache.fetchUrl song)
else do
localPath <- liftCache state (Cache.lookupCache (Song.uid song))
case localPath of
Just path -> return $ Just path
Nothing -> liftSession state (NetEase.fetchUrl song)
fetchLyrics :: (MonadIO m) => State -> Song.Song -> m Song.Lyrics
fetchLyrics state@State {..} song = if isLocal source
then liftSession state (Cache.fetchLyrics song)
else liftSession state (NetEase.fetchLyrics song)
fetchMore :: (MonadIO m) => State -> m State
fetchMore state@State {..} = do
new <- S.fromList <$> fetch state
return state { playSequence = playSequence S.>< new }
play :: (MonadIO m) => State -> m State
play state@State {..}
| currentIndex == 0 = return state
| otherwise = do
let onBegin () = let Song.Song {..} = playSequence `S.index` (currentIndex - 1) in callProcess "notify-send" [title, intercalate " / " artists ++ "\n" ++ album]
let onTerminate e = when e (postEvent (UserEventPending False))
let onProgress p = postEvent (UserEventUpdateProgress p)
let onLyrics l = postEvent (UserEventUpdateLyrics l)
liftPlayer state $ FM.play (playSequence `S.index` (currentIndex - 1)) (fetchUrl state) (fetchLyrics state) onBegin onTerminate onProgress onLyrics
return state { focusedIndex = currentIndex
, stopped = False
, progress = (0, 0)
, currentLyrics = []
, autoProceed = True }
pause :: (MonadIO m) => State -> m State
pause state = do
liftPlayer state FM.pause
return state { autoProceed = False }
resume :: (MonadIO m) => State -> m State
resume state = do
liftPlayer state FM.resume
return state { autoProceed = True }
stop :: (MonadIO m) => State -> m State
stop state = do
liftPlayer state FM.stop
return state { stopped = True
, progress = (0, 0)
, currentLyrics = []
, autoProceed = False }
setVolume :: (MonadIO m) => State -> Int -> m State
setVolume state@State {..} d = do
let newVolume = max 0 $ min 100 $ FM.playerVolume player + d
let newState = state { player = player { FM.playerVolume = newVolume } }
liftPlayer newState FM.updateVolume
return newState
toggleMute :: (MonadIO m) => State -> m State
toggleMute state@State {..} = do
let newMuted = not (FM.playerMuted player)
let newState = state { player = player { FM.playerMuted = newMuted } }
liftPlayer newState FM.updateVolume
return newState
cacheSong :: (MonadIO m) => State -> m State
cacheSong state@State {..} = do
when (focusedIndex /= 0 && not (isLocal source)) $ do
let song = playSequence `S.index` (focusedIndex - 1)
url <- fetchUrl state song
when (isJust url) $ liftCache state $ FM.cacheSong song (fromJust url)
return state
deleteSong :: (MonadIO m) => State -> m State
deleteSong state@State {..} = do
state <- stop state
if (focusedIndex /= 0 && isLocal source)
then liftCache state $ do
FM.deleteSong $ playSequence `S.index` (focusedIndex - 1)
let (heads, tails) = S.splitAt (focusedIndex - 1) playSequence
let newSequence = heads `mappend` S.drop 1 tails
let newIndex = if S.length tails == 1 then focusedIndex - 1 else focusedIndex
return state { playSequence = newSequence, focusedIndex = newIndex }
else return state
musicPlayerDraw :: State -> [UI.Widget]
musicPlayerDraw State {..} = [ui]
where
formatSong Song.Song {..} = printf "%s - %s - %s" title (intercalate " / " artists) album :: String
formatTime time = printf "%02d:%02d" minute second :: String
where (minute, second) = floor time `quotRem` 60 :: (Int, Int)
ui = UI.vBox [UI.separator, title , UI.separator, bar1, UI.separator, bar2, UI.separator, lyrics, UI.separator, playList]
title = UI.mkYellow $ UI.hCenter $ UI.str body
where
body | stopped = "[停止]"
| otherwise = formatSong $ playSequence `S.index` (currentIndex - 1)
bar1 | stopped = UI.separator
| otherwise = UI.mkGreen $ UI.hCenter $ UI.str $ printf "[%s] (%s/%s)" (make '>' total occupied) (formatTime cur) (formatTime len)
where
(len, cur) = progress
ratio = if len == 0 then 0 else cur / len
total = 35 :: Int
occupied = ceiling $ fromIntegral total * ratio
make c total occupied = replicate occupied c ++ replicate (total - occupied) ' '
bar2 = UI.mkCyan $ UI.hCenter $ UI.str $ unwords [playModeBar, volumeBar]
where
playModeBar = printf "[播放模式: %s]" (show1 playMode)
volumeBar | FM.playerMuted player = "[静音]"
| otherwise = printf "[音量: %d%%]" (FM.playerVolume player)
lyrics = UI.mkRed $ UI.hCenter $ UI.str $ if null currentLyrics then " " else currentLyrics
playList = UI.viewport "vp" UI.Vertical $ UI.hCenter $ UI.vBox $ do
(song, index) <- zip (toList playSequence) [1 .. ]
let mkItem | index == focusedIndex = UI.visible . UI.mkCyan . UI.str . UI.mkFocused
| otherwise = UI.mkWhite . UI.str . UI.mkUnfocused
return $ mkItem (show index ++ ". " ++ formatSong song)
musicPlayerEvent :: State -> Event -> UI.EventM (UI.Next State)
musicPlayerEvent state@State {..} event = case event of
UserEventFetchMore -> UI.continue =<< fetchMore state
UserEventPending forceProceed -> do
pState <- liftIO $ atomically $ readTVar (FM.playerState player)
if (forceProceed || autoProceed) && (isStopped pState)
then do
let needMore = currentIndex == S.length playSequence && playMode == Stream
state@State {..} <- if needMore then fetchMore state else return state
nextIndex <- case playMode of
Stream -> return $ min (S.length playSequence) (currentIndex + 1)
LoopOne -> return currentIndex
LoopAll -> return $ if currentIndex + 1 > S.length playSequence then 1 else currentIndex + 1
Shuffle -> liftIO $ randomRIO (1, S.length playSequence)
UI.continue =<< play state { currentIndex = nextIndex }
else UI.continue state
UserEventUpdateProgress p -> UI.continue state { progress = p }
UserEventUpdateLyrics l -> UI.continue state { currentLyrics = l }
VtyEvent (UI.EvKey UI.KEsc []) -> if stopped
then UI.halt state
else UI.continue =<< stop state
VtyEvent (UI.EvKey (UI.KChar ' ') []) -> musicPlayerEvent state (VtyEvent $ UI.EvKey UI.KEnter [])
VtyEvent (UI.EvKey UI.KEnter []) -> do
pState <- liftIO $ atomically $ readTVar (FM.playerState player)
UI.continue =<< case pState of
Playing _ -> if currentIndex == focusedIndex
then pause state
else do
state@State {..} <- stop state
play state { currentIndex = focusedIndex }
Paused _ -> if currentIndex == focusedIndex
then resume state
else do
state@State {..} <- stop state
play state { currentIndex = focusedIndex }
Stopped -> play state { currentIndex = focusedIndex }
VtyEvent (UI.EvKey UI.KUp []) -> UI.continue state { focusedIndex = max 0 (focusedIndex - 1) }
VtyEvent (UI.EvKey UI.KDown []) -> do
let needMore = focusedIndex == S.length playSequence && playMode == Stream
state@State {..} <- if needMore then liftIO (fetchMore state) else return state
UI.continue state { focusedIndex = min (S.length playSequence) (focusedIndex + 1) }
VtyEvent (UI.EvKey (UI.KChar '-') []) -> UI.continue =<< setVolume state (-10)
VtyEvent (UI.EvKey (UI.KChar '=') []) -> UI.continue =<< setVolume state 10
VtyEvent (UI.EvKey (UI.KChar 'm') []) -> UI.continue =<< toggleMute state
VtyEvent (UI.EvKey (UI.KChar 'n') []) -> do
state <- stop state
liftIO $ postEvent (UserEventPending True)
UI.continue state
VtyEvent (UI.EvKey (UI.KChar 'o') []) -> UI.suspendAndResume $ do
newPlayMode <- menuSelection_ [minBound .. maxBound] (Just playMode) "播放模式"
return $ case newPlayMode of
Just newPlayMode -> state { playMode = newPlayMode }
Nothing -> state
VtyEvent (UI.EvKey (UI.KChar 'c') []) -> UI.continue =<< cacheSong state
VtyEvent (UI.EvKey (UI.KChar 'C') []) -> UI.continue =<< deleteSong state
_ -> UI.continue state
musicPlayerApp :: UI.App State Event
musicPlayerApp = UI.App { UI.appDraw = musicPlayerDraw
, UI.appStartEvent = return
, UI.appHandleEvent = musicPlayerEvent
, UI.appAttrMap = const UI.defaultAttributeMap
, UI.appLiftVtyEvent = VtyEvent
, UI.appChooseCursor = UI.neverShowCursor
}
musicPlayer_ :: MusicSource -> SomeSession -> Cache -> IO ()
musicPlayer_ source session cache = void $ do
player <- FM.initPlayer
chan <- newChan
let postEvent = writeChan chan
let state = State { session = session
, player = player
, cache = cache
, source = source
, playMode = defaultPlayMode source
, playSequence = S.empty
, stopped = True
, currentIndex = 0
, focusedIndex = 0
, progress = (0, 0)
, currentLyrics = []
, postEvent = postEvent
, autoProceed = False
}
postEvent UserEventFetchMore
UI.customMain (UI.mkVty def) chan musicPlayerApp state
musicPlayer :: MusicSource -> SomeSession -> Cache -> ContT () IO ()
musicPlayer source session cache = ContT (const $ musicPlayer_ source session cache)