diff --git a/elm.json b/elm.json index ee45c2b..7c53ae6 100644 --- a/elm.json +++ b/elm.json @@ -6,11 +6,13 @@ "elm-version": "0.19.1", "dependencies": { "direct": { + "STTR13/ziplist": "1.3.0", "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", + "elm/time": "1.0.0", "elm/url": "1.0.0", "jims/html-parser": "1.0.0", "mdgriffith/elm-ui": "1.1.8" @@ -19,8 +21,8 @@ "elm/bytes": "1.0.8", "elm/file": "1.0.5", "elm/parser": "1.1.0", - "elm/time": "1.0.0", "elm/virtual-dom": "1.0.2", + "elm-community/list-extra": "8.2.4", "rtfeldman/elm-hex": "1.0.0" } }, diff --git a/src/Colors.elm b/src/Colors.elm new file mode 100644 index 0000000..350c61d --- /dev/null +++ b/src/Colors.elm @@ -0,0 +1,28 @@ +module Colors exposing (blackFont, greyBackground, primary, primaryOver, white) + +import Element + + +primary : Element.Color +primary = + Element.rgb255 50 115 220 + + +primaryOver : Element.Color +primaryOver = + Element.rgb255 35 102 209 + + +white : Element.Color +white = + Element.rgb255 255 255 255 + + +greyBackground : Element.Color +greyBackground = + Element.rgba255 0 0 0 0.7 + + +blackFont : Element.Color +blackFont = + Element.rgb255 54 54 54 diff --git a/src/Consts.elm b/src/Consts.elm new file mode 100644 index 0000000..cd94e5d --- /dev/null +++ b/src/Consts.elm @@ -0,0 +1,21 @@ +module Consts exposing (homeFontSize, homePadding, name, url) + + +url : String +url = + "twitch.tforgione.fr" + + +name : String +name = + url + + +homeFontSize : Int +homeFontSize = + 25 + + +homePadding : Int +homePadding = + 15 diff --git a/src/Core.elm b/src/Core.elm index a8c28c3..0f30d23 100644 --- a/src/Core.elm +++ b/src/Core.elm @@ -1,4 +1,4 @@ -module Core exposing (Model(..), Msg(..), init) +module Core exposing (FullModel(..), Model, Msg(..), Page(..), init, update) import Browser.Navigation import Json.Decode as Decode @@ -7,17 +7,38 @@ import Twitch import Url -type Model +type FullModel + = Unloaded + | Loaded Model + + +type alias Model = + { playlists : List Twitch.Playlist + , page : Page + } + + +type Page = Home type Msg = Noop - | ReceivedPlaylists (List Twitch.Playlist) + | PlaylistsReceived (List Twitch.Playlist) -init : Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) +init : Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( FullModel, Cmd Msg ) init _ _ _ = - ( Home - , Task.perform ReceivedPlaylists Twitch.fetchPlaylists + ( Unloaded + , Task.perform PlaylistsReceived Twitch.fetchPlaylists ) + + +update : Msg -> FullModel -> ( FullModel, Cmd Msg ) +update msg model = + case msg of + Noop -> + ( model, Cmd.none ) + + PlaylistsReceived playlists -> + ( Loaded { playlists = playlists, page = Home }, Cmd.none ) diff --git a/src/Main.elm b/src/Main.elm index 3948505..6cdfde0 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -3,16 +3,15 @@ module Main exposing (main) import Browser import Core import Json.Decode as Decode -import Updates import Url import Views -main : Program Decode.Value Core.Model Core.Msg +main : Program Decode.Value Core.FullModel Core.Msg main = Browser.application { init = Core.init - , update = Updates.update + , update = Core.update , view = Views.view , subscriptions = \_ -> Sub.none , onUrlChange = onUrlChange diff --git a/src/Twitch.elm b/src/Twitch.elm index 86aa6a3..219bb09 100644 --- a/src/Twitch.elm +++ b/src/Twitch.elm @@ -1,4 +1,9 @@ -module Twitch exposing (Playlist, Video, fetchPlaylists) +module Twitch exposing + ( Playlist + , Video + , fetchPlaylists + , playlistMiniatureUrl + ) import Html.Parser import Http @@ -7,7 +12,8 @@ import Task exposing (Task) type alias Playlist = - { name : String + { url : String + , name : String , videos : List Video } @@ -15,7 +21,7 @@ type alias Playlist = type alias Video = { name : String , url : String - , duration : Float + , duration : Int } @@ -31,6 +37,16 @@ get { url, resolver } = } +playlistMiniatureUrl : Playlist -> String +playlistMiniatureUrl playlist = + case List.head playlist.videos of + Just v -> + v.url ++ "miniature-050.png" + + _ -> + "" + + fetchPlaylists : Task x (List Playlist) fetchPlaylists = fetchPlaylistPath |> Task.andThen fetchPlaylistsMapper @@ -40,25 +56,18 @@ fetchPlaylistPath : Task x (List String) fetchPlaylistPath = get { url = "/videos" - , resolver = Http.stringResolver parsePlaylistPath + , resolver = Http.stringResolver parseHrefs } fetchPlaylist : String -> Task x Playlist fetchPlaylist name = - get - { url = "/videos/" ++ name ++ "/description.json" - , resolver = Http.stringResolver parsePlaylistName - } + fetchPlaylistName name |> Task.andThen (\a -> - Task.map (\b -> Playlist a b) - (get - { url = "/videos/" ++ name - , resolver = Http.stringResolver parsePlaylistVideoPaths - } - |> Task.andThen (\c -> fetchVideos name c) - ) + fetchPlaylistVideoPaths name + |> Task.andThen (\c -> fetchVideos name c) + |> Task.map (Playlist name a) ) @@ -69,7 +78,7 @@ fetchVideo playlist video = "/videos/" ++ playlist ++ video in get - { url = "/videos/" ++ playlist ++ "/" ++ video ++ "/description.json" + { url = url ++ "description.json" , resolver = Http.stringResolver (parseVideo url) } @@ -79,6 +88,22 @@ fetchVideos playlist videos = Task.sequence (List.map (fetchVideo playlist) videos) +fetchPlaylistName : String -> Task x String +fetchPlaylistName path = + get + { url = "/videos/" ++ path ++ "description.json" + , resolver = Http.stringResolver parsePlaylistName + } + + +fetchPlaylistVideoPaths : String -> Task x (List String) +fetchPlaylistVideoPaths name = + get + { url = "/videos/" ++ name + , resolver = Http.stringResolver parseHrefs + } + + parseVideo : String -> Http.Response String -> Result x Video parseVideo url result = case result of @@ -114,37 +139,8 @@ parsePlaylistName result = Ok "" -parsePlaylistVideoPaths : Http.Response String -> Result x (List String) -parsePlaylistVideoPaths result = - case result of - Http.GoodStatus_ _ content -> - let - withoutDoctype = - if String.startsWith " List.drop 1 |> String.join "\n" - - else - content - - decoded = - Html.Parser.run withoutDoctype - - hrefs = - Result.map findHrefs decoded - in - case hrefs of - Ok h -> - Ok (List.filter (String.endsWith "/") h) - - _ -> - Ok [] - - _ -> - Ok [] - - -parsePlaylistPath : Http.Response String -> Result x (List String) -parsePlaylistPath result = +parseHrefs : Http.Response String -> Result x (List String) +parseHrefs result = case result of Http.GoodStatus_ _ content -> let @@ -211,4 +207,4 @@ decodeVideo : String -> Decode.Decoder Video decodeVideo url = Decode.map2 (\x y -> Video x url y) (Decode.field "title" Decode.string) - (Decode.field "duration" Decode.float) + (Decode.map Basics.round (Decode.field "duration" Decode.float)) diff --git a/src/Updates.elm b/src/Updates.elm deleted file mode 100644 index 81862b5..0000000 --- a/src/Updates.elm +++ /dev/null @@ -1,17 +0,0 @@ -module Updates exposing (update) - -import Core - - -update : Core.Msg -> Core.Model -> ( Core.Model, Cmd Core.Msg ) -update msg model = - case msg of - Core.Noop -> - ( model, Cmd.none ) - - Core.ReceivedPlaylists playlists -> - let - _ = - Debug.log "p" playlists - in - ( model, Cmd.none ) diff --git a/src/Views.elm b/src/Views.elm index a5fa85f..bfce5a8 100644 --- a/src/Views.elm +++ b/src/Views.elm @@ -1,12 +1,197 @@ module Views exposing (view) import Browser +import Colors +import Consts import Core -import Element +import Element exposing (Element) +import Element.Background as Background +import Element.Border as Border +import Element.Font as Font +import Element.Input as Input +import Twitch -view : Core.Model -> Browser.Document Core.Msg +view : Core.FullModel -> Browser.Document Core.Msg view model = - { title = "twitch.tforgione.fr" - , body = [ Element.layout [] (Element.text "sup") ] + { title = Consts.url + , body = [ Element.layout [] (viewContent model) ] } + + +viewContent : Core.FullModel -> Element Core.Msg +viewContent model = + let + content = + case model of + Core.Unloaded -> + Element.none + + Core.Loaded submodel -> + mainView submodel + in + Element.column [ Element.width Element.fill ] [ topBar, content ] + + +mainView : Core.Model -> Element Core.Msg +mainView model = + case model.page of + Core.Home -> + playlistsView model.playlists + + +topBar : Element Core.Msg +topBar = + Element.row + [ Element.width Element.fill + , Background.color Colors.primary + , Font.color Colors.white + , Font.size Consts.homeFontSize + ] + [ homeButton ] + + +homeButton : Element Core.Msg +homeButton = + Input.button + [ Element.padding Consts.homePadding + , Element.height Element.fill + , Element.mouseOver [ Background.color Colors.primaryOver ] + , Font.bold + ] + { label = Element.text Consts.name + , onPress = Nothing + } + + +playlistsView : List Twitch.Playlist -> Element Core.Msg +playlistsView playlists = + let + empty = + Element.el [ Element.width Element.fill ] Element.none + + views = + List.map playlistView playlists + + grouped = + group 4 views + + rows = + grouped + |> List.map (\x -> List.map (Maybe.withDefault empty) x) + |> List.map (Element.row [ Element.spacing 10, Element.width Element.fill ]) + + final = + Element.column [ Element.padding 10, Element.spacing 10, Element.width Element.fill ] rows + in + final + + +playlistView : Twitch.Playlist -> Element Core.Msg +playlistView playlist = + let + image = + Element.image [ Element.width Element.fill, Element.height Element.fill, Element.inFront inFront ] + { description = "", src = Twitch.playlistMiniatureUrl playlist } + + length = + List.length playlist.videos + + label = + String.fromInt length + ++ " video" + ++ (if length > 1 then + "s" + + else + "" + ) + + inFront = + Element.el + [ Element.alignBottom + , Element.alignRight + , Background.color Colors.greyBackground + , Border.rounded 5 + , Element.padding 5 + , Font.color Colors.white + ] + (Element.text label) + |> Element.el [ Element.alignBottom, Element.alignRight, Element.padding 5 ] + + display = + Element.column [ Element.width Element.fill, Element.spacing 10 ] + [ image + , Element.paragraph [ Font.bold, Font.color Colors.blackFont ] [ Element.text playlist.name ] + ] + + button = + Input.button [ Element.width Element.fill, Element.alignTop ] + { label = display + , onPress = Nothing + } + in + button + + +formatTime : Int -> String +formatTime time = + String.fromInt (toHours time) + ++ ":" + ++ String.fromInt (toMinutes time) + ++ ":" + ++ String.fromInt (toSeconds time) + + +toHours : Int -> Int +toHours i = + i // 3600 + + +toMinutes : Int -> Int +toMinutes i = + modBy 3600 i // 60 + + +toSeconds : Int -> Int +toSeconds i = + modBy 60 i + + +group : Int -> List a -> List (List (Maybe a)) +group size list = + let + grouped = + List.map (List.map Just) (groupAux size list []) + + groupedRev = + List.reverse grouped + + ( firstFixed, tail ) = + case groupedRev of + h :: t -> + ( List.concat [ h, List.repeat (size - List.length h) Nothing ], t ) + + [] -> + ( [], [] ) + + fixed = + (firstFixed :: tail) |> List.reverse + in + fixed + + +groupAux : Int -> List a -> List (List a) -> List (List a) +groupAux size list acc = + if List.isEmpty list then + acc + + else + let + groupHead = + List.take size list + + groupTail = + List.drop size list + in + groupAux size groupTail (groupHead :: acc)