port module Main exposing (main) import Ambient import Browser import Browser.Events import Filter exposing (Filter) import Grid import Json.Decode as Decode import Json.Encode as Encode import Key exposing (Direction(..), Key(..)) import ListUtil import Model exposing (Model, Zone(..)) import Msg exposing (Msg(..)) import Playback exposing (PlayState(..), Playback(..)) import Process import Random import Stations exposing (Station) import Task import Time import Toast import View port playStream : String -> Cmd msg port resumeStream : () -> Cmd msg port pauseStream : () -> Cmd msg port stopStream : () -> Cmd msg port saveFavs : String -> Cmd msg port respondScreenSaver : Encode.Value -> Cmd msg port exitApp : () -> Cmd msg port scrollIntoView : String -> Cmd msg port streamEvent : (String -> msg) -> Sub msg port screenSaverRequest : (Decode.Value -> msg) -> Sub msg main : Program String Model Msg main = Browser.document { init = init , view = View.view , update = update , subscriptions = subscriptions } streamTimeoutMs : Int streamTimeoutMs = 15000 ambientAfterMs : Int ambientAfterMs = 30 * 1000 relocateEveryMs : Float relocateEveryMs = 45 * 1000 init : String -> ( Model, Cmd Msg ) init storedFavs = ( { activeFilter = Filter.All , zone = InGrid , focusedFilter = Filter.All , focusedCard = List.head Stations.all , playback = NotPlaying , favs = favsFromStorage storedFavs , ambient = Ambient.Off , toast = Toast.None , timeZone = Time.utc , now = Time.millisToPosix 0 , lastActivity = Time.millisToPosix 0 } , Cmd.batch [ Task.perform GotZone Time.here , Task.perform Tick Time.now ] ) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of NoOp -> ( model, Cmd.none ) GotZone zone -> ( { model | timeZone = zone }, Cmd.none ) Tick posix -> tick { model | now = posix } KeyPressed key -> pressKey key model PointerActivity -> ( wake model, Cmd.none ) StreamStarted -> streamStarted model StreamFailed -> streamFailed model ScreenSaverActive timestamp -> answerScreenSaver timestamp model FocusFilter filter -> ( { model | zone = InSidebar, focusedFilter = filter }, Cmd.none ) FocusCard st -> ( { model | zone = InGrid, focusedCard = Just st }, Cmd.none ) SelectFilter filter -> selectFilter filter model CardClicked st -> togglePlay st model AmbientRelocate -> startAmbientFade model AmbientFadedOut -> ( finishAmbientFade model, Cmd.none ) tick : Model -> ( Model, Cmd Msg ) tick model = let ticked = { model | toast = Toast.prune model.now model.toast } in case ticked.playback of Playing st (Connecting failAt) -> failWhenOverdue st failAt ticked _ -> enterAmbientWhenIdle ticked failWhenOverdue : Station -> Time.Posix -> Model -> ( Model, Cmd Msg ) failWhenOverdue st failAt model = if Time.posixToMillis model.now >= Time.posixToMillis failAt then failStream st model else ( model, Cmd.none ) enterAmbientWhenIdle : Model -> ( Model, Cmd Msg ) enterAmbientWhenIdle model = if Playback.isLive model.playback && not (Ambient.isOn model.ambient) && idleLongEnough model then ( enterAmbient model, Cmd.none ) else ( model, Cmd.none ) idleLongEnough : Model -> Bool idleLongEnough model = Time.posixToMillis model.now - Time.posixToMillis model.lastActivity > ambientAfterMs touch : Model -> Model touch model = { model | lastActivity = model.now } wake : Model -> Model wake model = touch { model | ambient = Ambient.dismiss model.ambient } pressKey : Key -> Model -> ( Model, Cmd Msg ) pressKey key model = case model.ambient of Ambient.On _ -> ( wake model, Cmd.none ) _ -> handleKey key (touch model) handleKey : Key -> Model -> ( Model, Cmd Msg ) handleKey key model = case key of Arrow direction -> move direction model Select -> pressSelect model Back -> onBack model PlayPause -> togglePause model Stop -> stopPlayback model ChannelUp -> playNeighbor ListUtil.before model ChannelDown -> playNeighbor ListUtil.after model Favorite -> toggleFav model Unknown -> ( model, Cmd.none ) move : Direction -> Model -> ( Model, Cmd Msg ) move direction model = case model.zone of InGrid -> moveGrid direction model InSidebar -> moveSidebar direction model pressSelect : Model -> ( Model, Cmd Msg ) pressSelect model = case ( model.zone, model.focusedCard ) of ( InSidebar, _ ) -> selectFilter model.focusedFilter model ( InGrid, Just st ) -> togglePlay st model ( InGrid, Nothing ) -> ( model, Cmd.none ) onBack : Model -> ( Model, Cmd Msg ) onBack model = case model.playback of Playing _ _ -> stopAndWarn model NotPlaying -> ( model, exitApp () ) stopAndWarn : Model -> ( Model, Cmd Msg ) stopAndWarn model = let ( stopped, cmd ) = stopPlayback model in ( notify "PLAYBACK STOPPED — PRESS BACK AGAIN TO EXIT" stopped, cmd ) moveGrid : Direction -> Model -> ( Model, Cmd Msg ) moveGrid direction model = case Grid.move direction model.focusedCard (Model.visibleStations model) of Grid.To st -> ( { model | zone = InGrid, focusedCard = Just st }, scrollToCard st ) Grid.Exit -> ( { model | zone = InSidebar }, Cmd.none ) Grid.Stay -> ( model, Cmd.none ) moveSidebar : Direction -> Model -> ( Model, Cmd Msg ) moveSidebar direction model = case direction of Right -> enterGrid model Down -> ( { model | focusedFilter = Filter.next model.focusedFilter }, Cmd.none ) Up -> ( { model | focusedFilter = Filter.previous model.focusedFilter }, Cmd.none ) Left -> ( model, Cmd.none ) enterGrid : Model -> ( Model, Cmd Msg ) enterGrid model = ( { model | zone = InGrid } , model.focusedCard |> Maybe.map scrollToCard |> Maybe.withDefault Cmd.none ) selectFilter : Filter -> Model -> ( Model, Cmd Msg ) selectFilter filter model = let focusedCard = List.head (Filter.stations model.favs filter) in ( { model | activeFilter = filter , focusedFilter = filter , focusedCard = focusedCard } , focusedCard |> Maybe.map scrollToCard |> Maybe.withDefault Cmd.none ) playStation : Station -> Model -> ( Model, Cmd Msg ) playStation st model = ( setPlayback (Playing st (connectingUntil model.now)) model , playStream st.streamUrl ) connectingUntil : Time.Posix -> PlayState connectingUntil now = Connecting (Time.millisToPosix (Time.posixToMillis now + streamTimeoutMs)) togglePlay : Station -> Model -> ( Model, Cmd Msg ) togglePlay st model = if Playback.isOn st model.playback then stopPlayback model else playStation st model togglePause : Model -> ( Model, Cmd Msg ) togglePause model = case model.playback of Playing st (Connecting _) -> pause st model Playing st Live -> pause st model Playing st Paused -> resume st model _ -> playFocused model pause : Station -> Model -> ( Model, Cmd Msg ) pause st model = ( setPlayback (Playing st Paused) model, pauseStream () ) resume : Station -> Model -> ( Model, Cmd Msg ) resume st model = ( setPlayback (Playing st (connectingUntil model.now)) model , resumeStream () ) playFocused : Model -> ( Model, Cmd Msg ) playFocused model = case model.focusedCard of Just st -> playStation st model Nothing -> ( model, Cmd.none ) stopPlayback : Model -> ( Model, Cmd Msg ) stopPlayback model = ( setPlayback NotPlaying model, stopStream () ) failStream : Station -> Model -> ( Model, Cmd Msg ) failStream st model = ( setPlayback (Playing st Failed) (notify "STREAM UNAVAILABLE — TRY ANOTHER STATION" model) , pauseStream () ) streamStarted : Model -> ( Model, Cmd Msg ) streamStarted model = case model.playback of Playing st (Connecting _) -> ( setPlayback (Playing st Live) model, Cmd.none ) _ -> ( model, Cmd.none ) streamFailed : Model -> ( Model, Cmd Msg ) streamFailed model = case model.playback of Playing st _ -> failStream st model NotPlaying -> ( model, Cmd.none ) setPlayback : Playback -> Model -> Model setPlayback playback model = { model | playback = playback , ambient = if Playback.isLive playback then model.ambient else Ambient.dismiss model.ambient } type alias Neighbor = (Station -> Bool) -> List Station -> Maybe Station playNeighbor : Neighbor -> Model -> ( Model, Cmd Msg ) playNeighbor neighbor model = case stepTarget neighbor model of Just st -> playFocusing st model Nothing -> ( model, Cmd.none ) stepTarget : Neighbor -> Model -> Maybe Station stepTarget neighbor model = case playingNeighbor neighbor model of Just st -> Just st Nothing -> model.focusedCard playingNeighbor : Neighbor -> Model -> Maybe Station playingNeighbor neighbor model = Playback.station model.playback |> Maybe.andThen (\playing -> neighbor (\st -> st.id == playing.id) (Model.visibleStations model)) playFocusing : Station -> Model -> ( Model, Cmd Msg ) playFocusing st model = let ( played, playCmd ) = playStation st { model | zone = InGrid, focusedCard = Just st } in ( played, Cmd.batch [ scrollToCard st, playCmd ] ) toggleFav : Model -> ( Model, Cmd Msg ) toggleFav model = case ( model.zone, model.focusedCard ) of ( InGrid, Just st ) -> toggleFavorite st model _ -> ( notify "FOCUS A STATION FIRST, THEN PRESS YELLOW" model, Cmd.none ) toggleFavorite : Station -> Model -> ( Model, Cmd Msg ) toggleFavorite st model = let updated = if List.member st.id model.favs then notify "REMOVED FROM FAVORITES" { model | favs = List.filter ((/=) st.id) model.favs } else notify "★ ADDED TO FAVORITES" { model | favs = st.id :: model.favs } in ( keepFocusInView st (Model.visibleStations model) updated , saveFavs (favsToStorage updated.favs) ) keepFocusInView : Station -> List Station -> Model -> Model keepFocusInView st previousView model = if List.any (\s -> s.id == st.id) (Model.visibleStations model) then model else { model | focusedCard = ListUtil.replacementFor (\s -> s.id == st.id) previousView } favsFromStorage : String -> List Stations.Id favsFromStorage raw = Decode.decodeString (Decode.list Stations.idDecoder) raw |> Result.withDefault [] favsToStorage : List Stations.Id -> String favsToStorage favs = Encode.encode 0 (Encode.list Stations.encodeId favs) {-| Veto the screensaver while playing (audio isn't "activity" to it). -} answerScreenSaver : Encode.Value -> Model -> ( Model, Cmd Msg ) answerScreenSaver timestamp model = if screenSaverBlocked model.playback then ( enterAmbient model, screenSaverAnswer False timestamp ) else ( model, screenSaverAnswer True timestamp ) screenSaverBlocked : Playback -> Bool screenSaverBlocked playback = case playback of Playing _ (Connecting _) -> True Playing _ Live -> True _ -> False screenSaverAnswer : Bool -> Encode.Value -> Cmd Msg screenSaverAnswer ack timestamp = respondScreenSaver (Encode.object [ ( "ack", Encode.bool ack ) , ( "timestamp", timestamp ) ] ) enterAmbient : Model -> Model enterAmbient model = case ( Ambient.isOn model.ambient, ambientSubject model ) of ( False, Just st ) -> let ( x, y ) = ambientPositionAt model.now in { model | ambient = Ambient.enter x y st } _ -> model ambientSubject : Model -> Maybe Station ambientSubject model = case Playback.station model.playback of Just st -> Just st Nothing -> model.focusedCard startAmbientFade : Model -> ( Model, Cmd Msg ) startAmbientFade model = if Ambient.isOn model.ambient then ( { model | ambient = Ambient.startFade model.ambient }, ambientFadeDelay ) else ( model, Cmd.none ) finishAmbientFade : Model -> Model finishAmbientFade model = if Ambient.isOn model.ambient then let ( x, y ) = ambientPositionAt model.now in { model | ambient = Ambient.placeAt x y model.ambient } else model ambientFadeDelay : Cmd Msg ambientFadeDelay = Process.sleep 900 |> Task.perform (\_ -> AmbientFadedOut) ambientPositionAt : Time.Posix -> ( Float, Float ) ambientPositionAt now = Random.initialSeed (Time.posixToMillis now) |> Random.step (Random.pair (Random.float 0 52) (Random.float 0 42)) |> Tuple.first scrollToCard : Station -> Cmd Msg scrollToCard st = scrollIntoView ("card-" ++ Stations.idToString st.id) notify : String -> Model -> Model notify note model = { model | toast = Toast.show model.now note } subscriptions : Model -> Sub Msg subscriptions model = Sub.batch [ clockTicks , keyPresses , pointerActivity model , streamEvents , screenSaverRequests , ambientRelocations model ] clockTicks : Sub Msg clockTicks = Time.every 1000 Tick keyPresses : Sub Msg keyPresses = Browser.Events.onKeyDown (Decode.map KeyPressed Key.decoder) pointerActivity : Model -> Sub Msg pointerActivity model = if Playback.isLive model.playback || Ambient.isOn model.ambient then Sub.batch [ Browser.Events.onMouseMove (Decode.succeed PointerActivity) , Browser.Events.onClick (Decode.succeed PointerActivity) ] else Sub.none streamEvents : Sub Msg streamEvents = streamEvent (\event -> if event == "playing" then StreamStarted else StreamFailed ) screenSaverRequests : Sub Msg screenSaverRequests = screenSaverRequest (\request -> Decode.decodeValue screenSaverDecoder request |> Result.withDefault NoOp ) screenSaverDecoder : Decode.Decoder Msg screenSaverDecoder = Decode.field "state" Decode.string |> Decode.andThen (\state -> if state == "Active" then Decode.map ScreenSaverActive (Decode.field "timestamp" Decode.value) else Decode.fail "not active" ) ambientRelocations : Model -> Sub Msg ambientRelocations model = if Ambient.isOn model.ambient then Time.every relocateEveryMs (\_ -> AmbientRelocate) else Sub.none