packages feed

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)