A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #223 from icidasset/cover-view

Album covers

authored by

Steven Vandevelde and committed by
GitHub
cd8fb7f8 003ba02e

+2201 -246
+1
elm.json
··· 28 28 "elm/time": "1.0.0", 29 29 "elm/url": "1.0.0", 30 30 "elm/virtual-dom": "1.0.2", 31 + "elm-community/dict-extra": "2.4.0", 31 32 "elm-community/html-extra": "3.3.0", 32 33 "elm-community/list-extra": "8.2.3", 33 34 "elm-community/maybe-extra": "5.1.0",
+2
package.json
··· 21 21 "dependencies": { 22 22 "@tokenizer/http": "^0.5.2", 23 23 "blockstack": "https://gist.github.com/a888e02d7441aeb2af99263a3add0f73.git", 24 + "delay": "^4.3.0", 24 25 "fast-text-encoding": "^1.0.2", 25 26 "file-saver": "^2.0.2", 26 27 "jszip": "^3.4.0", 27 28 "lunr": "^2.3.8", 28 29 "music-metadata-browser": "^2.0.5", 30 + "p-retry": "^4.2.0", 29 31 "pep": "mpizenberg/elm-pep#071616d75ca61e261fdefc7b55bc46c34e44ea22", 30 32 "remotestoragejs": "^1.2.3", 31 33 "subworkers": "^1.0.1",
+4
src/Applications/Brain.elm
··· 110 110 GotSearchResults a -> 111 111 Tracks.gotSearchResults a 112 112 113 + MakeArtworkTrackUrls a -> 114 + Tracks.makeArtworkTrackUrls a 115 + 113 116 RemoveTracksBySourceId a -> 114 117 Tracks.removeBySourceId a 115 118 ··· 155 158 subscriptions model = 156 159 Sub.batch 157 160 [ Ports.fromAlien alien 161 + , Ports.makeArtworkTrackUrls MakeArtworkTrackUrls 158 162 , Ports.receiveSearchResults GotSearchResults 159 163 , Ports.receiveTags (ProcessingMsg << Processing.TagsStep) 160 164 , Ports.savedHypaethralBit (\_ -> UserMsg User.SaveNextHypaethralBit)
+6
src/Applications/Brain/Ports.elm
··· 55 55 port handlePendingBlockstackSignIn : String -> Cmd msg 56 56 57 57 58 + port provideArtworkTrackUrls : Json.Value -> Cmd msg 59 + 60 + 58 61 port redirectToBlockstackSignIn : () -> Cmd msg 59 62 60 63 ··· 96 99 97 100 98 101 port fromAlien : (Alien.Event -> msg) -> Sub msg 102 + 103 + 104 + port makeArtworkTrackUrls : (Json.Value -> msg) -> Sub msg 99 105 100 106 101 107 port receiveSearchResults : (List String -> msg) -> Sub msg
+58
src/Applications/Brain/Tracks/State.elm
··· 5 5 import Brain.Ports as Ports 6 6 import Brain.Types exposing (..) 7 7 import Brain.User.State as User 8 + import Dict 9 + import Dict.Ext as Dict 8 10 import Json.Decode as Json exposing (Decoder) 9 11 import Json.Encode 12 + import List.Extra as List 10 13 import Queue 11 14 import Return exposing (andThen, return) 12 15 import Return.Ext as Return 16 + import Sources exposing (Source) 17 + import Sources.Processing exposing (HttpMethod(..)) 18 + import Sources.Services 19 + import Time 13 20 import Tracks exposing (Track) 14 21 import Tracks.Encoding 15 22 ··· 87 94 Common.giveUI Alien.SearchTracks (Json.Encode.list Json.Encode.string results) 88 95 89 96 97 + makeArtworkTrackUrls : Json.Value -> Manager 98 + makeArtworkTrackUrls json model = 99 + json 100 + |> Json.decodeValue 101 + (Json.dict Json.string) 102 + |> Result.map 103 + (\dict -> 104 + let 105 + maybeSource = 106 + Maybe.andThen 107 + (\trackSourceId -> 108 + List.find 109 + (.id >> (==) trackSourceId) 110 + model.hypaethralUserData.sources 111 + ) 112 + (Dict.get "trackSourceId" dict) 113 + 114 + trackPath = 115 + Dict.fetch "trackPath" "" dict 116 + 117 + mkTrackUrl = 118 + makeTrackUrl model.currentTime trackPath maybeSource 119 + in 120 + dict 121 + |> Dict.remove "trackPath" 122 + |> Dict.remove "trackSourceId" 123 + |> Dict.insert "trackGetUrl" (mkTrackUrl Get) 124 + |> Dict.insert "trackHeadUrl" (mkTrackUrl Head) 125 + |> Json.Encode.dict identity Json.Encode.string 126 + |> Ports.provideArtworkTrackUrls 127 + |> return model 128 + ) 129 + |> Result.withDefault 130 + (Return.singleton model) 131 + 132 + 90 133 removeByPaths : { sourceId : String, paths : List String } -> Manager 91 134 removeByPaths args model = 92 135 User.saveTracksAndUpdateSearchIndex ··· 146 189 Tuple.pair 147 190 (Json.field "zipName" <| Json.string) 148 191 (Json.field "trackIds" <| Json.list Json.string) 192 + 193 + 194 + makeTrackUrl : Time.Posix -> String -> Maybe Source -> HttpMethod -> String 195 + makeTrackUrl timestamp trackPath maybeSource httpMethod = 196 + case maybeSource of 197 + Just source -> 198 + Sources.Services.makeTrackUrl 199 + source.service 200 + timestamp 201 + source.data 202 + httpMethod 203 + trackPath 204 + 205 + Nothing -> 206 + "<missing-source>"
+1
src/Applications/Brain/Types.elm
··· 49 49 ----------------------------------------- 50 50 | DownloadTracks Json.Value 51 51 | GotSearchResults (List String) 52 + | MakeArtworkTrackUrls Json.Value 52 53 | RemoveTracksBySourceId Json.Value 53 54 | RemoveTracksFromCache Json.Value 54 55 | Search Json.Value
+9 -2
src/Applications/UI.elm
··· 188 188 ----------------------------------------- 189 189 -- Tracks 190 190 ----------------------------------------- 191 + , cachedCovers = Nothing 191 192 , cachedTracks = [] 192 193 , cachedTracksOnly = False 193 194 , cachingTracksInProgress = [] 195 + , covers = [] 194 196 , favourites = [] 195 197 , favouritesOnly = False 196 198 , grouping = Nothing 197 199 , hideDuplicates = False 198 - , scene = Tracks.List 200 + , scene = Tracks.Covers 199 201 , searchResults = Nothing 200 202 , searchTerm = Nothing 203 + , selectedCover = Nothing 201 204 , selectedTrackIndexes = [] 202 - , sortBy = Tracks.Artist 205 + , sortBy = Tracks.Album 203 206 , sortDirection = Tracks.Asc 204 207 , tracks = Tracks.emptyCollection 205 208 ··· 548 551 -- Tracks 549 552 ----------------------------------------- 550 553 , Ports.downloadTracksFinished (\_ -> TracksMsg Tracks.DownloadFinished) 554 + , Ports.insertCoverCache (TracksMsg << Tracks.InsertCoverCache) 551 555 552 556 ----------------------------------------- 553 557 -- 📭 Other ··· 589 593 590 594 Alien.FinishedProcessingSources -> 591 595 SourcesMsg Sources.FinishedProcessing 596 + 597 + Alien.GotCachedCover -> 598 + TracksMsg (Tracks.GotCachedCover data) 592 599 593 600 Alien.HideLoadingScreen -> 594 601 ToggleLoadingScreen Off
+12 -5
src/Applications/UI/Adjunct.elm
··· 33 33 False 34 34 in 35 35 if m.focusedOnInput || not authenticated then 36 - -- Stop here if using input or not authenticated 37 - skip 36 + case m.pressedKeys of 37 + [ Keyboard.Escape ] -> 38 + hideOverlay m 39 + 40 + _ -> 41 + skip 38 42 39 43 else if Maybe.isJust model.alfred then 40 44 case m.pressedKeys of ··· 47 51 [ Keyboard.Enter ] -> 48 52 Alfred.runSelectedAction m 49 53 54 + [ Keyboard.Escape ] -> 55 + hideOverlay m 56 + 50 57 _ -> 51 58 skip 52 59 53 60 else 54 61 case m.pressedKeys of 55 - [ Keyboard.Escape ] -> 56 - hideOverlay m 57 - 58 62 [ Keyboard.ArrowLeft ] -> 59 63 Queue.rewind m 60 64 ··· 78 82 79 83 [ Keyboard.Character "S" ] -> 80 84 Queue.toggleShuffle m 85 + 86 + [ Keyboard.Escape ] -> 87 + hideOverlay m 81 88 82 89 _ -> 83 90 skip
+14 -3
src/Applications/UI/Common/State.elm
··· 10 10 import Return exposing (return) 11 11 import Return.Ext as Return 12 12 import Task 13 + import Tracks 13 14 import UI.Notifications 14 15 import UI.Page as Page exposing (Page) 15 16 import UI.Playlists.Directory 17 + import UI.Tracks.Scene.Covers 16 18 import UI.Tracks.Scene.List 17 19 import UI.Types as UI exposing (Manager, Msg) 18 20 ··· 37 39 38 40 39 41 forceTracksRerender : Manager 40 - forceTracksRerender = 41 - Browser.Dom.setViewportOf UI.Tracks.Scene.List.containerId 0 1 42 + forceTracksRerender model = 43 + let 44 + containerId = 45 + case model.scene of 46 + Tracks.Covers -> 47 + UI.Tracks.Scene.Covers.containerId 48 + 49 + Tracks.List -> 50 + UI.Tracks.Scene.List.containerId 51 + in 52 + Browser.Dom.setViewportOf containerId 0 1 42 53 |> Task.attempt (always UI.Bypass) 43 - |> Return.communicate 54 + |> return model 44 55 45 56 46 57 generateDirectoryPlaylists : Manager
+20 -7
src/Applications/UI/Interface/State.elm
··· 2 2 3 3 import Common exposing (Switch(..)) 4 4 import Debouncer.Basic as Debouncer 5 + import Maybe.Extra as Maybe 5 6 import Notifications 6 7 import Return exposing (return) 7 8 import Return.Ext as Return 9 + import Tracks 8 10 import UI.DnD as DnD 9 11 import UI.Page as Page 10 12 import UI.Playlists.State as Playlists 11 13 import UI.Ports as Ports 12 14 import UI.Queue.State as Queue 13 - import UI.Tracks.Types as Tracks 14 15 import UI.Types as UI exposing (..) 15 16 import User.Layer exposing (..) 16 17 ··· 91 92 92 93 Page.Index -> 93 94 case model.scene of 95 + Tracks.Covers -> 96 + -- TODO 97 + Return.singleton m 98 + 94 99 Tracks.List -> 95 100 Playlists.moveTrackInSelected 96 101 { to = Maybe.withDefault 0 (DnD.modelTarget d) } ··· 110 115 111 116 hideOverlay : Manager 112 117 hideOverlay model = 113 - Return.singleton 114 - { model 115 - | alfred = Nothing 116 - , confirmation = Nothing 117 - , contextMenu = Nothing 118 - } 118 + if Maybe.isJust model.contextMenu then 119 + Return.singleton { model | contextMenu = Nothing } 120 + 121 + else if Maybe.isJust model.confirmation then 122 + Return.singleton { model | confirmation = Nothing } 123 + 124 + else if Maybe.isJust model.alfred then 125 + Return.singleton { model | alfred = Nothing } 126 + 127 + else if Maybe.isJust model.selectedCover then 128 + Return.singleton { model | selectedCover = Nothing } 129 + 130 + else 131 + Return.singleton model 119 132 120 133 121 134 preferredColorSchemaChanged : { dark : Bool } -> Manager
-3
src/Applications/UI/Kit.elm
··· 407 407 [ C.absolute 408 408 , C.bg_cover 409 409 , C.bg_no_repeat 410 - , C.duration_1000 411 410 , C.h_0 412 411 , C.left_full 413 412 , C.opacity_025 414 413 , C.pt_full 415 414 , C.top_0 416 - , C.transition 417 - , C.transition_opacity 418 415 , C.z_0 419 416 420 417 -- Dark mode
+7 -1
src/Applications/UI/Ports.elm
··· 18 18 port copyToClipboard : String -> Cmd msg 19 19 20 20 21 + port loadAlbumCovers : () -> Cmd msg 22 + 23 + 21 24 port pause : () -> Cmd msg 22 25 23 26 ··· 49 52 port downloadTracksFinished : (() -> msg) -> Sub msg 50 53 51 54 55 + port fromAlien : (Alien.Event -> msg) -> Sub msg 56 + 57 + 52 58 port indicateTouchDevice : (() -> msg) -> Sub msg 53 59 54 60 55 - port fromAlien : (Alien.Event -> msg) -> Sub msg 61 + port insertCoverCache : (Json.Value -> msg) -> Sub msg 56 62 57 63 58 64 port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg
+5 -2
src/Applications/UI/Tracks/Scene.elm
··· 4 4 import Conditional exposing (..) 5 5 import Css.Classes as C 6 6 import Html exposing (Html, text) 7 + import Html.Attributes as A 7 8 import Material.Icons as Icons 8 9 import Material.Icons.Types exposing (Coloring(..)) 9 10 import Tracks ··· 21 22 |> Maybe.map .name 22 23 |> Maybe.withDefault "Unknown" 23 24 in 24 - chunk 25 - [ C.font_display 25 + brick 26 + [ A.style "height" "18px" ] 27 + [ C.box_content 28 + , C.font_display 26 29 , C.font_semibold 27 30 , C.leading_normal 28 31 , C.pb_3
+857
src/Applications/UI/Tracks/Scene/Covers.elm
··· 1 + module UI.Tracks.Scene.Covers exposing (containerId, scrollToNowPlaying, scrollToTop, view) 2 + 3 + import Browser.Dom as Dom 4 + import Chunky exposing (..) 5 + import Color exposing (Color) 6 + import Color.Ext as Color 7 + import Color.Manipulate as Color 8 + import Conditional exposing (ifThenElse) 9 + import Coordinates 10 + import Css.Classes as C 11 + import Dict exposing (Dict) 12 + import Html exposing (Html, text) 13 + import Html.Attributes as A exposing (class, id, style, tabindex) 14 + import Html.Events as E 15 + import Html.Events.Extra.Mouse as Mouse 16 + import Html.Lazy 17 + import InfiniteList 18 + import Json.Decode as Decode 19 + import List.Ext as List 20 + import List.Extra as List 21 + import Material.Icons as Icons 22 + import Material.Icons.Types exposing (Coloring(..)) 23 + import Maybe.Extra as Maybe 24 + import Queue 25 + import Task 26 + import Tracks exposing (..) 27 + import UI.DnD as DnD 28 + import UI.Kit 29 + import UI.Queue.Types as Queue 30 + import UI.Tracks.Scene as Scene 31 + import UI.Tracks.Scene.List 32 + import UI.Tracks.Types exposing (Msg(..)) 33 + import UI.Types as UI exposing (Msg(..)) 34 + 35 + 36 + 37 + -- 🗺 38 + 39 + 40 + type alias Dependencies = 41 + { bgColor : Maybe Color 42 + , cachedCovers : Maybe (Dict String String) 43 + , covers : List Cover 44 + , darkMode : Bool 45 + , favouritesOnly : Bool 46 + , infiniteList : InfiniteList.Model 47 + , isVisible : Bool 48 + , nowPlaying : Maybe Queue.Item 49 + , selectedCover : Maybe Cover 50 + , selectedTrackIndexes : List Int 51 + , sortBy : SortBy 52 + , sortDirection : SortDirection 53 + , viewportHeight : Float 54 + , viewportWidth : Float 55 + } 56 + 57 + 58 + type alias ItemDependencies = 59 + { cachedCovers : Maybe (Dict String String) 60 + , columns : Int 61 + , containerWidth : Int 62 + , nowPlaying : Maybe Queue.Item 63 + , sortBy : SortBy 64 + } 65 + 66 + 67 + view : Dependencies -> Html Msg 68 + view deps = 69 + Html.Lazy.lazy view_ deps 70 + 71 + 72 + view_ : Dependencies -> Html Msg 73 + view_ deps = 74 + chunk 75 + [ C.flex 76 + , C.flex_basis_0 77 + , C.flex_col 78 + , C.flex_grow 79 + , C.relative 80 + ] 81 + [ collectionView deps 82 + , case deps.selectedCover of 83 + Just cover -> 84 + singleCoverView cover deps 85 + 86 + Nothing -> 87 + nothing 88 + ] 89 + 90 + 91 + 92 + -- 🏞 ░░ COLLECTION 93 + 94 + 95 + collectionView : Dependencies -> Html Msg 96 + collectionView deps = 97 + let 98 + amountOfCovers = 99 + List.length deps.covers 100 + in 101 + brick 102 + ((::) 103 + (tabindex (ifThenElse deps.isVisible 0 -1)) 104 + viewAttributes 105 + ) 106 + [ C.flex_basis_0 107 + , C.flex_grow 108 + , C.outline_none 109 + , C.overflow_x_hidden 110 + , C.overflow_y_auto 111 + , C.relative 112 + , C.scrolling_touch 113 + , C.text_almost_sm 114 + ] 115 + [ chunk 116 + [ C.flex 117 + , C.items_center 118 + , C.mt_5 119 + , C.mx_5 120 + ] 121 + [ sortGroupButtons deps.sortBy 122 + 123 + -- 124 + , chunk 125 + [ C.flex 126 + , C.flex_auto 127 + , C.items_center 128 + , C.justify_end 129 + , C.text_base05 130 + , C.text_right 131 + , C.text_xs 132 + ] 133 + [ text (String.fromInt amountOfCovers) 134 + , case deps.sortBy of 135 + Album -> 136 + text " albums" 137 + 138 + Artist -> 139 + text " artists" 140 + 141 + _ -> 142 + nothing 143 + , text " " 144 + , slab 145 + Html.span 146 + [ deps.sortBy 147 + |> SortBy 148 + |> TracksMsg 149 + |> E.onClick 150 + 151 + -- 152 + , case deps.sortDirection of 153 + Asc -> 154 + A.title "Sorted alphabetically ascending" 155 + 156 + Desc -> 157 + A.title "Sorted alphabetically descending" 158 + ] 159 + [ C.cursor_pointer 160 + , C.ml_1 161 + , C.opacity_60 162 + ] 163 + [ case deps.sortDirection of 164 + Asc -> 165 + Icons.arrow_downward 16 Inherit 166 + 167 + Desc -> 168 + Icons.arrow_upward 16 Inherit 169 + ] 170 + ] 171 + ] 172 + 173 + -- 174 + , infiniteListView deps 175 + ] 176 + 177 + 178 + containerId : String 179 + containerId = 180 + "diffuse__track-covers" 181 + 182 + 183 + scrollToNowPlaying : Float -> List Cover -> IdentifiedTrack -> Cmd Msg 184 + scrollToNowPlaying viewportWidth covers nowPlaying = 185 + let 186 + columns = 187 + determineColumns viewportWidth 188 + 189 + containerWidth = 190 + determineContainerWidth viewportWidth 191 + 192 + rowHeightArgs = 193 + { columns = columns 194 + , containerWidth = containerWidth 195 + } 196 + 197 + { rows, nowPlayingRowIndex } = 198 + coverRows (Just nowPlaying) columns covers 199 + in 200 + case nowPlayingRowIndex of 201 + Just idx -> 202 + rows 203 + |> List.take idx 204 + |> List.foldl (\a -> (+) <| dynamicRowHeight rowHeightArgs 0 a) 0 205 + |> toFloat 206 + |> (+) 46 207 + |> Dom.setViewportOf containerId 0 208 + |> Task.attempt (always Bypass) 209 + 210 + Nothing -> 211 + Cmd.none 212 + 213 + 214 + scrollToTop : Cmd Msg 215 + scrollToTop = 216 + Task.attempt (always UI.Bypass) (Dom.setViewportOf containerId 0 0) 217 + 218 + 219 + viewAttributes : List (Html.Attribute Msg) 220 + viewAttributes = 221 + [ InfiniteList.onScroll (InfiniteListMsg >> TracksMsg) 222 + , id containerId 223 + ] 224 + 225 + 226 + 227 + -- 🏞 ░░ SINGLE COVER 228 + 229 + 230 + singleCoverView : Cover -> Dependencies -> Html Msg 231 + singleCoverView cover deps = 232 + let 233 + derivedColors = 234 + UI.Tracks.Scene.List.deriveColors 235 + { bgColor = deps.bgColor 236 + , darkMode = deps.darkMode 237 + } 238 + 239 + columns = 240 + determineColumns deps.viewportWidth 241 + 242 + condensedView = 243 + columns < 4 244 + in 245 + brick 246 + [ tabindex (ifThenElse deps.isVisible 0 -1) ] 247 + [ C.absolute 248 + , C.bg_white 249 + , C.flex_basis_0 250 + , C.flex_grow 251 + , C.inset_0 252 + , C.leading_tight 253 + , C.outline_none 254 + , C.overflow_x_hidden 255 + , C.overflow_y_auto 256 + , C.text_almost_sm 257 + 258 + -- Dark mode 259 + ------------ 260 + , C.dark__bg_darkest_hour 261 + ] 262 + [ chunk 263 + [ C.flex 264 + , C.font_semibold 265 + , C.h_8 266 + , C.items_center 267 + , C.leading_none 268 + , C.minus_ml_2 269 + , C.mt_5 270 + , C.px_5 271 + ] 272 + [ headerButton 273 + [ E.onClick (TracksMsg DeselectCover) ] 274 + { active = False 275 + , label = Icons.arrow_back 16 Inherit 276 + } 277 + 278 + -- 279 + , headerButton 280 + [ Mouse.onClick (showCoverMenu cover) ] 281 + { active = True 282 + , label = Icons.more_horiz 16 Inherit 283 + } 284 + ] 285 + 286 + -- 287 + , chunk 288 + [ C.mb_6 289 + , C.minus_top_px 290 + , C.mt_4 291 + , C.relative 292 + 293 + -- 294 + , ifThenElse condensedView C.block C.flex 295 + , ifThenElse condensedView C.mx_5 C.ml_5 296 + ] 297 + [ itemView 298 + { clickable = False, horizontal = condensedView } 299 + (compileItemDependencies deps) 300 + cover 301 + 302 + -- 303 + , chunk 304 + [ C.flex_auto 305 + , C.flex_basis_0 306 + , C.overflow_hidden 307 + , C.select_none 308 + 309 + -- 310 + , ifThenElse condensedView C.minus_mx_5 C.mx_5 311 + , ifThenElse condensedView C.px_1 C.px_0 312 + ] 313 + (List.indexedMap 314 + (UI.Tracks.Scene.List.defaultItemView 315 + { derivedColors = derivedColors 316 + , favouritesOnly = deps.favouritesOnly 317 + , nowPlaying = deps.nowPlaying 318 + , roundedCorners = True 319 + , selectedTrackIndexes = deps.selectedTrackIndexes 320 + , showAlbum = not cover.sameAlbum 321 + , showArtist = deps.sortBy /= Artist && not cover.sameArtist 322 + , showGroup = False 323 + } 324 + 0 325 + ) 326 + cover.tracks 327 + ) 328 + ] 329 + ] 330 + 331 + 332 + 333 + -- 🧕 334 + 335 + 336 + headerButton attributes { active, label } = 337 + brick 338 + attributes 339 + [ C.cursor_pointer 340 + , C.inline_flex 341 + , C.h_8 342 + , C.items_center 343 + , C.overflow_hidden 344 + , C.px_2 345 + , C.rounded 346 + 347 + -- 348 + , ifThenElse active C.bg_gray_300 C.bg_transparent 349 + , ifThenElse active C.dark__bg_base01 C.dark__bg_transparent 350 + ] 351 + [ chunk 352 + [ C.mt_px, C.pt_px ] 353 + [ label ] 354 + ] 355 + 356 + 357 + showCoverMenu : Cover -> Mouse.Event -> Msg 358 + showCoverMenu cover = 359 + .clientPos 360 + >> Coordinates.fromTuple 361 + >> (TracksMsg << ShowCoverMenuWithSmallDelay cover) 362 + 363 + 364 + 365 + -- SORTING 366 + 367 + 368 + sortGroupButtons : SortBy -> Html Msg 369 + sortGroupButtons sortBy = 370 + chunk 371 + [ C.flex 372 + , C.h_8 373 + , C.items_center 374 + , C.leading_none 375 + , C.mr_3 376 + , C.text_xs 377 + , C.tracking_tad_further 378 + ] 379 + [ sortGroupButton 380 + { current = sortBy, btn = Artist } 381 + (chunk 382 + [ C.inline_flex, C.items_center ] 383 + [ inline [ C.mr_px ] [ Icons.people_alt 16 Inherit ] 384 + , inline [ C.ml_1, C.mt_px, C.pl_px, C.pt_px ] [ text "Artists" ] 385 + ] 386 + ) 387 + 388 + -- 389 + , sortGroupButton 390 + { current = sortBy, btn = Album } 391 + (chunk 392 + [ C.inline_flex, C.items_center ] 393 + [ inline [ C.mr_px ] [ Icons.album 16 Inherit ] 394 + , inline [ C.ml_1, C.mt_px, C.pt_px ] [ text "Albums" ] 395 + ] 396 + ) 397 + ] 398 + 399 + 400 + sortGroupButton : { current : SortBy, btn : SortBy } -> Html Msg -> Html Msg 401 + sortGroupButton { current, btn } label = 402 + headerButton 403 + [ btn 404 + |> SortBy 405 + |> TracksMsg 406 + |> E.onClick 407 + 408 + -- 409 + , class C.mr_1 410 + ] 411 + { active = current == btn 412 + , label = label 413 + } 414 + 415 + 416 + 417 + -- INFINITE LIST 418 + 419 + 420 + infiniteListView : Dependencies -> Html Msg 421 + infiniteListView deps = 422 + let 423 + itemDeps = 424 + compileItemDependencies deps 425 + 426 + rowHeightArgs = 427 + { columns = itemDeps.columns 428 + , containerWidth = itemDeps.containerWidth 429 + } 430 + in 431 + { itemView = rowView itemDeps 432 + , itemHeight = InfiniteList.withVariableHeight (dynamicRowHeight rowHeightArgs) 433 + , containerHeight = round deps.viewportHeight - 262 434 + } 435 + |> InfiniteList.config 436 + |> InfiniteList.withCustomContainer infiniteListContainer 437 + |> (\config -> 438 + InfiniteList.view 439 + config 440 + deps.infiniteList 441 + (deps.covers 442 + |> coverRows Nothing itemDeps.columns 443 + |> .rows 444 + ) 445 + ) 446 + 447 + 448 + infiniteListContainer : 449 + List ( String, String ) 450 + -> List (Html msg) 451 + -> Html msg 452 + infiniteListContainer styles = 453 + styles 454 + |> List.filterMap 455 + (\( k, v ) -> 456 + if k == "padding" then 457 + Nothing 458 + 459 + else 460 + Just (style k v) 461 + ) 462 + |> List.append listStyles 463 + |> Html.div 464 + 465 + 466 + compileItemDependencies : Dependencies -> ItemDependencies 467 + compileItemDependencies deps = 468 + { cachedCovers = deps.cachedCovers 469 + , columns = determineColumns deps.viewportWidth 470 + , containerWidth = determineContainerWidth deps.viewportWidth 471 + , nowPlaying = deps.nowPlaying 472 + , sortBy = deps.sortBy 473 + } 474 + 475 + 476 + listStyles : List (Html.Attribute msg) 477 + listStyles = 478 + [ C.leading_tight 479 + , C.pl_5 480 + , C.pt_4 481 + ] 482 + |> String.join " " 483 + |> class 484 + |> List.singleton 485 + 486 + 487 + 488 + -- ROWS 489 + 490 + 491 + determineContainerWidth : Float -> Int 492 + determineContainerWidth viewportWidth = 493 + min 768 (round viewportWidth - 32) 494 + 495 + 496 + dynamicRowHeight : { columns : Int, containerWidth : Int } -> Int -> List Cover -> Int 497 + dynamicRowHeight { columns, containerWidth } _ coverRow = 498 + let 499 + rowHeight = 500 + (containerWidth - 16) // columns + (46 + 16) 501 + in 502 + let 503 + shouldRenderGroup = 504 + coverRow 505 + |> List.head 506 + |> Maybe.andThen (.tracks >> List.head) 507 + |> Maybe.map (Tuple.first >> Tracks.shouldRenderGroup) 508 + |> Maybe.withDefault False 509 + in 510 + if shouldRenderGroup then 511 + 42 + rowHeight 512 + 513 + else 514 + rowHeight 515 + 516 + 517 + coverRows : 518 + Maybe IdentifiedTrack 519 + -> Int 520 + -> List Cover 521 + -> { nowPlayingRowIndex : Maybe Int, rows : List (List Cover) } 522 + coverRows maybeNowPlaying columns covers = 523 + covers 524 + |> List.foldl 525 + (\cover { collection, current, nowPlayingRowIdx, trackGroup } -> 526 + let 527 + trackGroupCurr = 528 + cover.identifiedTrackCover 529 + |> Tuple.first 530 + |> .group 531 + |> Maybe.map .name 532 + 533 + npr addition = 534 + case ( maybeNowPlaying, nowPlayingRowIdx ) of 535 + ( Just ( _, t ), Nothing ) -> 536 + if List.member t.id cover.trackIds then 537 + Just (List.length collection + ifThenElse addition 1 0) 538 + 539 + else 540 + Nothing 541 + 542 + _ -> 543 + nowPlayingRowIdx 544 + in 545 + if List.length current < columns && (Maybe.isNothing trackGroup || trackGroupCurr == trackGroup) then 546 + { collection = collection 547 + , current = current ++ [ cover ] 548 + , nowPlayingRowIdx = npr False 549 + , trackGroup = trackGroupCurr 550 + } 551 + 552 + else 553 + { collection = collection ++ [ current ] 554 + , current = [ cover ] 555 + , nowPlayingRowIdx = npr True 556 + , trackGroup = trackGroupCurr 557 + } 558 + ) 559 + { current = [] 560 + , collection = [] 561 + , nowPlayingRowIdx = Nothing 562 + , trackGroup = Nothing 563 + } 564 + |> (\foldResult -> 565 + { nowPlayingRowIndex = foldResult.nowPlayingRowIdx 566 + , rows = foldResult.collection ++ [ foldResult.current ] 567 + } 568 + ) 569 + 570 + 571 + rowView : 572 + ItemDependencies 573 + -> Int 574 + -> Int 575 + -> List Cover 576 + -> Html Msg 577 + rowView itemDeps _ idx row = 578 + let 579 + maybeIdentifiers = 580 + row 581 + |> List.head 582 + |> Maybe.andThen (.tracks >> List.head) 583 + |> Maybe.map Tuple.first 584 + 585 + shouldRenderGroup = 586 + maybeIdentifiers 587 + |> Maybe.map Tracks.shouldRenderGroup 588 + |> Maybe.withDefault False 589 + in 590 + raw 591 + [ case ( shouldRenderGroup, maybeIdentifiers ) of 592 + ( True, Just identifiers ) -> 593 + chunk 594 + [ C.minus_ml_4 ] 595 + [ Scene.group { index = idx } identifiers ] 596 + 597 + _ -> 598 + nothing 599 + 600 + -- 601 + , chunk 602 + [ C.flex, C.flex_wrap ] 603 + (List.map (itemView { clickable = True, horizontal = False } itemDeps) row) 604 + ] 605 + 606 + 607 + 608 + -- ITEMS / COLUMNS 609 + 610 + 611 + determineColumns : Float -> Int 612 + determineColumns viewportWidth = 613 + let 614 + containerWidth = 615 + determineContainerWidth viewportWidth 616 + in 617 + if containerWidth < 260 then 618 + 1 619 + 620 + else if containerWidth < 480 then 621 + 2 622 + 623 + else if containerWidth <= 590 then 624 + 3 625 + 626 + else 627 + 4 628 + 629 + 630 + type alias ItemViewOptions = 631 + { clickable : Bool, horizontal : Bool } 632 + 633 + 634 + itemView : ItemViewOptions -> ItemDependencies -> Cover -> Html Msg 635 + itemView options deps cover = 636 + chunk 637 + [ C.antialiased 638 + , C.flex_shrink_0 639 + , C.font_semibold 640 + 641 + -- 642 + , ifThenElse options.horizontal C.flex C.block 643 + , ifThenElse options.horizontal C.mb_0 C.mb_5 644 + 645 + -- 646 + , case ( options.horizontal, deps.columns ) of 647 + ( True, _ ) -> 648 + C.w_auto 649 + 650 + ( False, 1 ) -> 651 + C.w_full 652 + 653 + ( False, 2 ) -> 654 + C.w_half 655 + 656 + ( False, 3 ) -> 657 + C.w_1_div_3 658 + 659 + _ -> 660 + C.w_1_div_4 661 + ] 662 + [ coverView options deps cover 663 + , metadataView options deps cover 664 + ] 665 + 666 + 667 + coverView : ItemViewOptions -> ItemDependencies -> Cover -> Html Msg 668 + coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover = 669 + let 670 + maybeBlobUrlFromCache = 671 + cachedCovers 672 + |> Maybe.withDefault Dict.empty 673 + |> Dict.get cover.key 674 + 675 + hasBackgroundImage = 676 + Maybe.isJust maybeBlobUrlFromCache 677 + 678 + nowPlayingId = 679 + Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying 680 + 681 + bgOrDataAttributes = 682 + case maybeBlobUrlFromCache of 683 + Just blobUrl -> 684 + [ A.style "background-image" ("url('" ++ blobUrl ++ "')") 685 + ] 686 + 687 + Nothing -> 688 + if Maybe.isJust cachedCovers then 689 + let 690 + ( identifiers, track ) = 691 + cover.identifiedTrackCover 692 + in 693 + [ A.attribute "data-key" cover.key 694 + , A.attribute "data-focus" cover.focus 695 + , A.attribute "data-filename" identifiers.filename 696 + , A.attribute "data-path" track.path 697 + , A.attribute "data-source-id" track.sourceId 698 + , A.attribute "data-various-artists" (ifThenElse cover.variousArtists "t" "f") 699 + ] 700 + 701 + else 702 + [] 703 + in 704 + chunk 705 + [ C.flex_shrink_0 706 + , C.mr_5 707 + , C.relative 708 + , C.select_none 709 + 710 + -- 711 + , ifThenElse clickable C.cursor_pointer C.cursor_default 712 + , ifThenElse horizontal C.h_32 C.h_0 713 + , ifThenElse horizontal C.mb_4 C.pt_full 714 + , ifThenElse horizontal C.w_32 C.w_auto 715 + ] 716 + [ brick 717 + (List.append 718 + bgOrDataAttributes 719 + (if clickable then 720 + [ E.onClick (TracksMsg <| SelectCover cover) 721 + , Mouse.onContextMenu (showCoverMenu cover) 722 + ] 723 + 724 + else 725 + [] 726 + ) 727 + ) 728 + [ C.absolute 729 + , C.bg_cover 730 + , C.bg_gray_300 731 + , C.mb_5 732 + , C.inset_0 733 + , C.rounded_md 734 + , C.shadow 735 + 736 + -- 737 + , ifThenElse horizontal C.h_32 C.h_auto 738 + 739 + -- Dark mode 740 + ------------ 741 + , C.dark__bg_white_025 742 + ] 743 + [ if not hasBackgroundImage then 744 + chunk 745 + [ C.absolute 746 + , C.left_half 747 + , C.minus_translate_x_half 748 + , C.minus_translate_y_half 749 + , C.text_gray_400 750 + , C.top_half 751 + , C.transform 752 + 753 + -- Dark mode 754 + ------------ 755 + , C.dark__text_base01 756 + ] 757 + [ Icons.album 26 Inherit ] 758 + 759 + else 760 + nothing 761 + 762 + -- Now playing? 763 + , if List.member nowPlayingId cover.trackIds then 764 + let 765 + dropShadow = 766 + "drop-shadow(hsla(0, 0%, 0%, 0.2) 1px 1px 2.5px)" 767 + in 768 + brick 769 + [ style "-webkit-filter" dropShadow 770 + , style "filter" dropShadow 771 + ] 772 + [ C.absolute 773 + , C.bottom_0 774 + , C.mb_3 775 + , C.mr_3 776 + , C.right_0 777 + , C.text_white 778 + ] 779 + [ Icons.headset 16 Inherit ] 780 + 781 + else 782 + nothing 783 + ] 784 + ] 785 + 786 + 787 + metadataView : ItemViewOptions -> ItemDependencies -> Cover -> Html Msg 788 + metadataView { clickable, horizontal } { cachedCovers, sortBy } cover = 789 + let 790 + { identifiedTrackCover } = 791 + cover 792 + 793 + ( _, track ) = 794 + identifiedTrackCover 795 + in 796 + brick 797 + (if clickable then 798 + [ E.onClick (TracksMsg <| SelectCover cover) 799 + , Mouse.onContextMenu (showCoverMenu cover) 800 + ] 801 + 802 + else 803 + [] 804 + ) 805 + [ C.mr_5 806 + , C.tracking_tad_closer 807 + 808 + -- 809 + , ifThenElse clickable C.cursor_pointer C.cursor_default 810 + , ifThenElse horizontal C.mt_0 C.minus_mt_5 811 + , ifThenElse horizontal C.overflow_hidden C.overflow_auto 812 + , ifThenElse horizontal C.pt_0 C.pt_2 813 + ] 814 + [ chunk 815 + [ C.mt_px 816 + , C.pt_px 817 + , C.truncate 818 + ] 819 + [ case sortBy of 820 + Album -> 821 + text track.tags.album 822 + 823 + Artist -> 824 + text track.tags.artist 825 + 826 + _ -> 827 + nothing 828 + ] 829 + 830 + -- 831 + , chunk 832 + [ C.mt_px 833 + , C.pt_px 834 + , C.text_base05 835 + , C.text_xs 836 + , C.truncate 837 + ] 838 + [ case sortBy of 839 + Album -> 840 + if cover.variousArtists then 841 + text "Various Artists" 842 + 843 + else 844 + text track.tags.artist 845 + 846 + Artist -> 847 + case List.length cover.trackIds of 848 + 1 -> 849 + Html.text "1 track" 850 + 851 + n -> 852 + Html.text (String.fromInt n ++ " tracks") 853 + 854 + _ -> 855 + nothing 856 + ] 857 + ]
+128 -91
src/Applications/UI/Tracks/Scene/List.elm
··· 1 - module UI.Tracks.Scene.List exposing (Dependencies, DerivedColors, containerId, scrollToNowPlaying, scrollToTop, view) 1 + module UI.Tracks.Scene.List exposing (Dependencies, DerivedColors, containerId, defaultItemView, deriveColors, scrollToNowPlaying, scrollToTop, view) 2 2 3 3 import Browser.Dom as Dom 4 4 import Chunky exposing (..) ··· 129 129 containerId : String 130 130 containerId = 131 131 "diffuse__track-list" 132 - 133 - 134 - infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 135 - infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = 136 - let 137 - color = 138 - Maybe.withDefault UI.Kit.colors.text deps.bgColor 139 - 140 - derivedColors = 141 - if deps.darkMode then 142 - { background = Color.toCssString color 143 - , subtle = Color.toCssString (Color.darken 0.1 color) 144 - , text = Color.toCssString (Color.darken 0.475 color) 145 - } 146 - 147 - else 148 - { background = Color.toCssString (Color.fadeOut 0.625 color) 149 - , subtle = Color.toCssString (Color.fadeOut 0.575 color) 150 - , text = Color.toCssString (Color.darken 0.3 color) 151 - } 152 - in 153 - { itemView = 154 - case maybeDnD of 155 - Just dnd -> 156 - playlistItemView 157 - favouritesOnly 158 - nowPlaying 159 - searchTerm 160 - selectedTrackIndexes 161 - dnd 162 - deps.showAlbum 163 - derivedColors 164 - 165 - _ -> 166 - defaultItemView 167 - favouritesOnly 168 - nowPlaying 169 - selectedTrackIndexes 170 - deps.showAlbum 171 - derivedColors 172 - 173 - -- 174 - , itemHeight = InfiniteList.withVariableHeight dynamicRowHeight 175 - , containerHeight = round deps.height 176 - } 177 - |> InfiniteList.config 178 - |> InfiniteList.withCustomContainer infiniteListContainer 179 - |> (\config -> 180 - InfiniteList.view 181 - config 182 - infiniteList 183 - harvest 184 - ) 185 132 186 133 187 134 scrollToNowPlaying : List IdentifiedTrack -> IdentifiedTrack -> Cmd Msg ··· 214 161 header : Bool -> Bool -> SortBy -> SortDirection -> Html Msg 215 162 header isPlaylist showAlbum sortBy sortDirection = 216 163 let 164 + showArtist = 165 + True 166 + 217 167 sortIcon = 218 168 (if sortDirection == Desc then 219 169 Icons.expand_less ··· 268 218 ] 269 219 270 220 else 271 - [ headerColumn "" 4.5 Nothing Bypass 272 - , headerColumn "Title" 52 (maybeSortIcon Title) (TracksMsg <| SortBy Title) 273 - , headerColumn "Artist" 43.5 (maybeSortIcon Artist) (TracksMsg <| SortBy Artist) 221 + [ headerColumn "" 5.75 Nothing Bypass 222 + , headerColumn "Title" 51.25 (maybeSortIcon Title) (TracksMsg <| SortBy Title) 223 + , headerColumn "Artist" 43 (maybeSortIcon Artist) (TracksMsg <| SortBy Artist) 274 224 ] 275 225 ) 276 226 ··· 339 289 -- INFINITE LIST 340 290 341 291 292 + infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 293 + infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = 294 + let 295 + derivedColors = 296 + deriveColors { bgColor = deps.bgColor, darkMode = deps.darkMode } 297 + in 298 + { itemView = 299 + case maybeDnD of 300 + Just dnd -> 301 + playlistItemView 302 + favouritesOnly 303 + nowPlaying 304 + searchTerm 305 + selectedTrackIndexes 306 + dnd 307 + deps.showAlbum 308 + derivedColors 309 + 310 + _ -> 311 + defaultItemView 312 + { derivedColors = derivedColors 313 + , favouritesOnly = favouritesOnly 314 + , nowPlaying = nowPlaying 315 + , roundedCorners = False 316 + , selectedTrackIndexes = selectedTrackIndexes 317 + , showAlbum = deps.showAlbum 318 + , showArtist = True 319 + , showGroup = True 320 + } 321 + 322 + -- 323 + , itemHeight = InfiniteList.withVariableHeight dynamicRowHeight 324 + , containerHeight = round deps.height 325 + } 326 + |> InfiniteList.config 327 + |> InfiniteList.withCustomContainer infiniteListContainer 328 + |> (\config -> 329 + InfiniteList.view 330 + config 331 + infiniteList 332 + harvest 333 + ) 334 + 335 + 342 336 infiniteListContainer : 343 337 List ( String, String ) 344 338 -> List (Html msg) ··· 357 351 |> Html.div 358 352 359 353 354 + deriveColors : { bgColor : Maybe Color, darkMode : Bool } -> DerivedColors 355 + deriveColors { bgColor, darkMode } = 356 + let 357 + color = 358 + Maybe.withDefault UI.Kit.colors.text bgColor 359 + in 360 + if darkMode then 361 + { background = Color.toCssString color 362 + , subtle = Color.toCssString (Color.darken 0.1 color) 363 + , text = Color.toCssString (Color.darken 0.475 color) 364 + } 365 + 366 + else 367 + { background = Color.toCssString (Color.fadeOut 0.625 color) 368 + , subtle = Color.toCssString (Color.fadeOut 0.575 color) 369 + , text = Color.toCssString (Color.darken 0.3 color) 370 + } 371 + 372 + 360 373 listStyles : List (Html.Attribute msg) 361 374 listStyles = 362 - [ C.pb_1 375 + [ C.pb_2 363 376 , C.pt_1 364 377 ] 365 378 |> String.join " " ··· 369 382 370 383 dynamicRowHeight : Int -> IdentifiedTrack -> Int 371 384 dynamicRowHeight _ ( i, t ) = 372 - let 373 - shouldRenderGroup = 374 - i.group 375 - |> Maybe.map (.firstInGroup >> (==) True) 376 - |> Maybe.withDefault False 377 - in 378 - if shouldRenderGroup then 379 - 32 + 18 + 16 + rowHeight 385 + if Tracks.shouldRenderGroup i then 386 + 16 + 18 + 12 + rowHeight 380 387 381 388 else 382 389 rowHeight ··· 386 393 -- INFINITE LIST ITEM 387 394 388 395 389 - defaultItemView : Bool -> Maybe Queue.Item -> List Int -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg 390 - defaultItemView favouritesOnly nowPlaying selectedTrackIndexes showAlbum derivedColors _ idx identifiedTrack = 396 + defaultItemView : 397 + { derivedColors : DerivedColors 398 + , favouritesOnly : Bool 399 + , nowPlaying : Maybe Queue.Item 400 + , roundedCorners : Bool 401 + , selectedTrackIndexes : List Int 402 + , showAlbum : Bool 403 + , showArtist : Bool 404 + , showGroup : Bool 405 + } 406 + -> Int 407 + -> Int 408 + -> IdentifiedTrack 409 + -> Html Msg 410 + defaultItemView args _ idx identifiedTrack = 391 411 let 412 + { derivedColors, favouritesOnly, nowPlaying, roundedCorners, selectedTrackIndexes, showAlbum, showArtist, showGroup } = 413 + args 414 + 392 415 ( identifiers, track ) = 393 416 identifiedTrack 394 417 395 418 shouldRenderGroup = 396 - identifiers.group 397 - |> Maybe.map (.firstInGroup >> (==) True) 398 - |> Maybe.withDefault False 419 + showGroup && Tracks.shouldRenderGroup identifiers 399 420 400 421 isSelected = 401 422 List.member idx selectedTrackIndexes ··· 422 443 Scene.group { index = idx } identifiers 423 444 424 445 else 425 - text "" 446 + nothing 426 447 427 448 -- 428 449 , brick ··· 439 460 ) 440 461 [ mouseContextMenuEvent identifiedTrack 441 462 , playEvent identifiedTrack 442 - , selectEvent identifiedTrack 463 + , selectEvent idx 443 464 ] 444 465 ] 445 466 ) ··· 449 470 -- 450 471 , ifThenElse identifiers.isMissing "" C.cursor_pointer 451 472 , ifThenElse isSelected C.font_semibold "" 473 + , ifThenElse roundedCorners C.rounded "" 452 474 453 475 -- 454 476 , ifThenElse ··· 471 493 C.dark__bg_near_darkest_hour 472 494 ) 473 495 ] 474 - (if showAlbum then 475 - [ favouriteColumn favouritesOnly favIdentifiers derivedColors 476 - , otherColumn "37.5%" False track.tags.title 477 - , otherColumn "29.0%" False track.tags.artist 478 - , otherColumn "29.0%" True track.tags.album 496 + (if not showArtist && not showAlbum then 497 + [ favouriteColumn "5.75%" favouritesOnly favIdentifiers derivedColors 498 + , otherColumn "94.25%" False track.tags.title 499 + ] 500 + 501 + else if not showArtist && showAlbum then 502 + [ favouriteColumn "5.75%" favouritesOnly favIdentifiers derivedColors 503 + , otherColumn "51.25%" False track.tags.title 504 + , otherColumn "43%" False track.tags.album 505 + ] 506 + 507 + else if showArtist && not showAlbum then 508 + [ favouriteColumn "5.75%" favouritesOnly favIdentifiers derivedColors 509 + , otherColumn "51.25%" False track.tags.title 510 + , otherColumn "43%" False track.tags.artist 479 511 ] 480 512 481 513 else 482 - [ favouriteColumn favouritesOnly favIdentifiers derivedColors 483 - , otherColumn "52%" False track.tags.title 484 - , otherColumn "43.5%" False track.tags.artist 514 + [ favouriteColumn defFavColWidth favouritesOnly favIdentifiers derivedColors 515 + , otherColumn "37.5%" False track.tags.title 516 + , otherColumn "29.0%" False track.tags.artist 517 + , otherColumn "29.0%" True track.tags.album 485 518 ] 486 519 ) 487 520 ] ··· 540 573 ) 541 574 [ mouseContextMenuEvent identifiedTrack 542 575 , playEvent identifiedTrack 543 - , selectEvent identifiedTrack 576 + , selectEvent idx 544 577 ] 545 578 546 579 -- ··· 583 616 ) 584 617 ] 585 618 (if showAlbum then 586 - [ favouriteColumn favouritesOnly favIdentifiers derivedColors 619 + [ favouriteColumn defFavColWidth favouritesOnly favIdentifiers derivedColors 587 620 , playlistIndexColumn (Maybe.withDefault 0 identifiers.indexInPlaylist) 588 621 , otherColumn "36.0%" False track.tags.title 589 622 , otherColumn "27.5%" False track.tags.artist ··· 591 624 ] 592 625 593 626 else 594 - [ favouriteColumn favouritesOnly favIdentifiers derivedColors 627 + [ favouriteColumn defFavColWidth favouritesOnly favIdentifiers derivedColors 595 628 , playlistIndexColumn (Maybe.withDefault 0 identifiers.indexInPlaylist) 596 629 , otherColumn "49.75%" False track.tags.title 597 630 , otherColumn "41.25%" False track.tags.artist ··· 676 709 ) 677 710 678 711 679 - selectEvent : IdentifiedTrack -> Html.Attribute Msg 680 - selectEvent ( i, _ ) = 712 + selectEvent : Int -> Html.Attribute Msg 713 + selectEvent idx = 681 714 Html.Events.custom 682 715 "tap" 683 716 (Decode.map2 ··· 686 719 case button of 687 720 0 -> 688 721 { shiftKey = shiftKey } 689 - |> MarkAsSelected i.indexInList 722 + |> MarkAsSelected idx 690 723 |> TracksMsg 691 724 692 725 _ -> ··· 745 778 -- COLUMNS 746 779 747 780 781 + defFavColWidth = 782 + "4.5%" 783 + 784 + 748 785 columnMinWidth = 749 786 "28px" 750 787 751 788 752 - favouriteColumn : Bool -> { isFavourite : Bool, indexInList : Int, isNowPlaying : Bool, isSelected : Bool } -> DerivedColors -> Html Msg 753 - favouriteColumn favouritesOnly identifiers derivedColors = 789 + favouriteColumn : String -> Bool -> { isFavourite : Bool, indexInList : Int, isNowPlaying : Bool, isSelected : Bool } -> DerivedColors -> Html Msg 790 + favouriteColumn columnWidth favouritesOnly identifiers derivedColors = 754 791 brick 755 792 ((++) 756 - [ identifiers.indexInList 793 + [ style "width" columnWidth 794 + , identifiers.indexInList 757 795 |> ToggleFavourite 758 796 |> TracksMsg 759 797 |> Html.Events.onClick ··· 796 834 [ style "color" color 797 835 , style "font-family" "or-favourites" 798 836 , style "min-width" columnMinWidth 799 - , style "width" "4.5%" 800 837 ] 801 838 802 839
+512 -13
src/Applications/UI/Tracks/State.elm
··· 1 1 module UI.Tracks.State exposing (..) 2 2 3 3 import Alien 4 + import Base64 4 5 import Common exposing (..) 6 + import Conditional exposing (ifThenElse) 5 7 import ContextMenu 6 8 import Coordinates exposing (Coordinates) 9 + import Dict 10 + import Dict.Extra as Dict 7 11 import Html.Events.Extra.Mouse as Mouse 8 12 import InfiniteList 9 13 import Json.Decode as Json ··· 16 20 import Queue 17 21 import Return exposing (andThen, return) 18 22 import Return.Ext as Return 19 - import Sources 23 + import Sources exposing (Source) 24 + import Sources.Processing exposing (HttpMethod(..)) 20 25 import Task 21 26 import Task.Extra as Task 27 + import Time 22 28 import Tracks exposing (..) 23 29 import Tracks.Collection as Collection 24 30 import Tracks.Encoding as Encoding ··· 29 35 import UI.Ports as Ports 30 36 import UI.Queue.State as Queue 31 37 import UI.Tracks.ContextMenu as Tracks 38 + import UI.Tracks.Scene.Covers 32 39 import UI.Tracks.Scene.List 33 40 import UI.Tracks.Types as Tracks exposing (..) 34 41 import UI.Types as UI exposing (Manager, Model, Msg(..)) ··· 82 89 StoredInCache a b -> 83 90 storedInCache a b 84 91 92 + --------- 93 + -- Covers 94 + --------- 95 + GotCachedCover a -> 96 + gotCachedCover a 97 + 98 + InsertCoverCache a -> 99 + insertCoverCache a 100 + 85 101 ----------------------------------------- 86 102 -- Collection 87 103 ----------------------------------------- ··· 112 128 ----------------------------------------- 113 129 -- Menus 114 130 ----------------------------------------- 131 + ShowCoverMenu a b -> 132 + showCoverMenu a b 133 + 134 + ShowCoverMenuWithSmallDelay a b -> 135 + showCoverMenuWithDelay a b 136 + 115 137 ShowTracksMenu a b c -> 116 138 showTracksMenu a b c 117 139 ··· 124 146 ----------------------------------------- 125 147 -- Scenes 126 148 ----------------------------------------- 149 + ChangeScene a -> 150 + changeScene a 151 + 152 + DeselectCover -> 153 + deselectCover 154 + 127 155 InfiniteListMsg a -> 128 156 infiniteListMsg a 157 + 158 + SelectCover a -> 159 + selectCover a 129 160 130 161 ----------------------------------------- 131 162 -- Search ··· 159 190 |> andThen search 160 191 161 192 193 + changeScene : Scene -> Manager 194 + changeScene scene model = 195 + (case scene of 196 + Covers -> 197 + Ports.loadAlbumCovers () 198 + 199 + List -> 200 + Cmd.none 201 + ) 202 + |> return { model | scene = scene, selectedCover = Nothing } 203 + |> andThen Common.forceTracksRerender 204 + |> andThen User.saveEnclosedUserData 205 + 206 + 162 207 clearCache : Manager 163 208 clearCache model = 164 209 model.cachedTracks ··· 180 225 { model | searchResults = Nothing, searchTerm = Nothing } 181 226 |> reviseCollection Collection.harvest 182 227 |> andThen User.saveEnclosedUserData 228 + 229 + 230 + deselectCover : Manager 231 + deselectCover model = 232 + Return.singleton { model | selectedCover = Nothing } 183 233 184 234 185 235 download : String -> List Track -> Manager ··· 265 315 |> andThen User.saveEnclosedUserData 266 316 267 317 318 + generateCovers : Manager 319 + generateCovers model = 320 + let 321 + groupFn = 322 + coverGroup model.sortBy 323 + 324 + makeCoverFn = 325 + makeCover model.sortBy 326 + in 327 + model.tracks.harvested 328 + |> List.indexedFoldr 329 + (\idx identifiedTrack { covers, gathering } -> 330 + let 331 + group = 332 + groupFn identifiedTrack 333 + 334 + ( identifiers, track ) = 335 + identifiedTrack 336 + 337 + { artist, album } = 338 + track.tags 339 + in 340 + if group /= gathering.previousGroup then 341 + -- New group, make cover for previous group 342 + let 343 + { collection, selectedCover } = 344 + makeCoverFn gathering covers model.selectedCover 345 + in 346 + { gathering = 347 + { acc = [ identifiedTrack ] 348 + , accIds = [ track.id ] 349 + , previousGroup = group 350 + , previousTrack = track 351 + , selectedCover = selectedCover 352 + 353 + -- 354 + , currentAlbumSequence = Just ( identifiedTrack, 1 ) 355 + , largestAlbumSequence = Nothing 356 + 357 + -- 358 + , currentAlbumFavsSequence = Just ( identifiedTrack, ifThenElse identifiers.isFavourite 1 0 ) 359 + , largestAlbumFavsSequence = Nothing 360 + 361 + -- 362 + , currentArtistSequence = Just ( identifiedTrack, 1 ) 363 + , largestArtistSequence = Nothing 364 + } 365 + , covers = 366 + case group of 367 + "<missing>" -> 368 + covers 369 + 370 + _ -> 371 + collection 372 + } 373 + 374 + else 375 + -- Same group 376 + { gathering = 377 + { acc = identifiedTrack :: gathering.acc 378 + , accIds = track.id :: gathering.accIds 379 + , previousGroup = group 380 + , previousTrack = track 381 + , selectedCover = gathering.selectedCover 382 + 383 + -- Album sequence 384 + ----------------- 385 + , currentAlbumSequence = 386 + if album /= gathering.previousTrack.tags.album then 387 + Just ( identifiedTrack, 1 ) 388 + 389 + else 390 + increaseSequence gathering.currentAlbumSequence 391 + 392 + -- 393 + , largestAlbumSequence = 394 + if album /= gathering.previousTrack.tags.album then 395 + resolveLargestSequence 396 + gathering.currentAlbumSequence 397 + gathering.largestAlbumSequence 398 + 399 + else 400 + gathering.largestAlbumSequence 401 + 402 + -- Album favourites sequence 403 + ---------------------------- 404 + , currentAlbumFavsSequence = 405 + if album /= gathering.previousTrack.tags.album then 406 + Just ( identifiedTrack, ifThenElse identifiers.isFavourite 1 0 ) 407 + 408 + else if identifiers.isFavourite then 409 + increaseSequence gathering.currentAlbumFavsSequence 410 + 411 + else 412 + gathering.currentAlbumFavsSequence 413 + 414 + -- 415 + , largestAlbumFavsSequence = 416 + if album /= gathering.previousTrack.tags.album then 417 + resolveLargestSequence 418 + gathering.currentAlbumFavsSequence 419 + gathering.largestAlbumFavsSequence 420 + 421 + else 422 + gathering.largestAlbumFavsSequence 423 + 424 + -- Artist sequence 425 + ------------------ 426 + , currentArtistSequence = 427 + if artist /= gathering.previousTrack.tags.artist then 428 + Just ( identifiedTrack, 1 ) 429 + 430 + else 431 + increaseSequence gathering.currentArtistSequence 432 + 433 + -- 434 + , largestArtistSequence = 435 + if artist /= gathering.previousTrack.tags.artist then 436 + resolveLargestSequence 437 + gathering.currentArtistSequence 438 + gathering.largestArtistSequence 439 + 440 + else 441 + gathering.largestArtistSequence 442 + } 443 + , covers = 444 + covers 445 + } 446 + ) 447 + { covers = 448 + [] 449 + , gathering = 450 + { acc = [] 451 + , accIds = [] 452 + , previousGroup = "" 453 + , previousTrack = emptyTrack 454 + , selectedCover = Nothing 455 + 456 + -- 457 + , currentAlbumSequence = Nothing 458 + , largestAlbumSequence = Nothing 459 + , currentAlbumFavsSequence = Nothing 460 + , largestAlbumFavsSequence = Nothing 461 + , currentArtistSequence = Nothing 462 + , largestArtistSequence = Nothing 463 + } 464 + } 465 + |> (\{ covers, gathering } -> 466 + let 467 + { collection, selectedCover } = 468 + makeCoverFn gathering covers model.selectedCover 469 + in 470 + { model 471 + | covers = collection 472 + , selectedCover = selectedCover 473 + } 474 + ) 475 + |> Return.communicate 476 + (Ports.loadAlbumCovers ()) 477 + |> andThen 478 + (case model.scene of 479 + Covers -> 480 + Common.forceTracksRerender 481 + 482 + List -> 483 + Return.singleton 484 + ) 485 + 486 + 487 + gotCachedCover : Json.Value -> Manager 488 + gotCachedCover json model = 489 + let 490 + cachedCovers = 491 + Maybe.withDefault Dict.empty model.cachedCovers 492 + in 493 + json 494 + |> Json.decodeValue 495 + (Json.map2 496 + Tuple.pair 497 + (Json.field "key" Json.string) 498 + (Json.field "url" Json.string) 499 + ) 500 + |> Result.map (\( key, url ) -> Dict.insert key url cachedCovers) 501 + |> Result.map (\dict -> { model | cachedCovers = Just dict }) 502 + |> Result.withDefault model 503 + |> Return.singleton 504 + 505 + 268 506 groupBy : Tracks.Grouping -> Manager 269 507 groupBy grouping model = 270 508 { model | grouping = Just grouping } ··· 279 517 280 518 infiniteListMsg : InfiniteList.Model -> Manager 281 519 infiniteListMsg infiniteList model = 282 - Return.singleton { model | infiniteList = infiniteList } 520 + return 521 + { model | infiniteList = infiniteList } 522 + (Ports.loadAlbumCovers ()) 523 + 524 + 525 + insertCoverCache : Json.Value -> Manager 526 + insertCoverCache json model = 527 + json 528 + |> Json.decodeValue (Json.dict Json.string) 529 + |> Result.map (\dict -> { model | cachedCovers = Just dict }) 530 + |> Result.withDefault model 531 + |> Return.singleton 283 532 284 533 285 534 markAsSelected : Int -> { shiftKey : Bool } -> Manager ··· 392 641 Return.singleton model 393 642 394 643 644 + selectCover : Cover -> Manager 645 + selectCover cover model = 646 + Return.singleton { model | selectedCover = Just cover } 647 + 648 + 395 649 setSearchResults : Json.Value -> Manager 396 650 setSearchResults json model = 397 651 case model.searchTerm of ··· 419 673 ) 420 674 421 675 676 + showCoverMenu : Cover -> Coordinates -> Manager 677 + showCoverMenu cover coordinates model = 678 + let 679 + menuDependencies = 680 + { cached = model.cachedTracks 681 + , cachingInProgress = model.cachingTracksInProgress 682 + , currentTime = model.currentTime 683 + , selectedPlaylist = model.selectedPlaylist 684 + , lastModifiedPlaylistName = model.lastModifiedPlaylist 685 + , showAlternativeMenu = False 686 + , sources = model.sources 687 + } 688 + in 689 + coordinates 690 + |> Tracks.trackMenu menuDependencies cover.tracks 691 + |> Common.showContextMenuWithModel model 692 + 693 + 694 + showCoverMenuWithDelay : Cover -> Coordinates -> Manager 695 + showCoverMenuWithDelay a b model = 696 + Tracks.ShowCoverMenu a b 697 + |> TracksMsg 698 + |> Task.doDelayed 250 699 + |> return model 700 + 701 + 422 702 showTracksMenu : Maybe Int -> { alt : Bool } -> Coordinates -> Manager 423 703 showTracksMenu maybeTrackIndex { alt } coordinates model = 424 704 let ··· 488 768 ) 489 769 |> Maybe.map 490 770 (case model.scene of 771 + Covers -> 772 + UI.Tracks.Scene.Covers.scrollToNowPlaying 773 + model.viewport.width 774 + model.covers 775 + 491 776 List -> 492 777 UI.Tracks.Scene.List.scrollToNowPlaying model.tracks.harvested 493 778 ) ··· 601 886 newFavourites = 602 887 Favourites.toggleInFavouritesList ( i, t ) model.favourites 603 888 604 - effect = 605 - if model.favouritesOnly then 606 - Collection.map (Favourites.toggleInTracksList t) >> Collection.harvest 889 + effect collection = 890 + collection 891 + |> Collection.map (Favourites.toggleInTracksList t) 892 + |> (if model.favouritesOnly then 893 + Collection.harvest 607 894 608 - else 609 - Collection.map (Favourites.toggleInTracksList t) 895 + else 896 + identity 897 + ) 898 + 899 + selectedCover = 900 + Maybe.map 901 + (\cover -> 902 + cover.tracks 903 + |> Favourites.toggleInTracksList t 904 + |> (\a -> { cover | tracks = a }) 905 + ) 906 + model.selectedCover 610 907 in 611 - { model | favourites = newFavourites } 908 + { model | favourites = newFavourites, selectedCover = selectedCover } 612 909 |> reviseCollection effect 613 910 |> andThen User.saveFavourites 911 + |> (if model.scene == Covers then 912 + andThen generateCovers 913 + 914 + else 915 + identity 916 + ) 614 917 615 918 Nothing -> 616 919 Return.singleton model ··· 672 975 newCollection.untouched 673 976 674 977 harvestChanged = 675 - Collection.harvestChanged 676 - model.tracks.harvested 677 - newCollection.harvested 978 + if collectionChanged then 979 + True 980 + 981 + else 982 + Collection.identifiedTracksChanged 983 + model.tracks.harvested 984 + newCollection.harvested 985 + 986 + arrangementChanged = 987 + if collectionChanged || harvestChanged then 988 + True 989 + 990 + else 991 + Collection.identifiedTracksChanged 992 + model.tracks.arranged 993 + newCollection.arranged 678 994 679 995 searchChanged = 680 996 newScrollContext /= model.tracks.scrollContext ··· 698 1014 } 699 1015 in 700 1016 (if collectionChanged then 701 - andThen Common.generateDirectoryPlaylists >> andThen Queue.reset 1017 + andThen Common.generateDirectoryPlaylists 1018 + >> andThen Queue.reset 1019 + >> andThen generateCovers 702 1020 703 1021 else if harvestChanged then 704 - andThen Queue.reset 1022 + andThen Queue.reset >> andThen generateCovers 1023 + 1024 + else if arrangementChanged then 1025 + andThen generateCovers 705 1026 706 1027 else 707 1028 identity ··· 712 1033 ----------------------------------------- 713 1034 , if searchChanged then 714 1035 case model.scene of 1036 + Covers -> 1037 + UI.Tracks.Scene.Covers.scrollToTop 1038 + 715 1039 List -> 716 1040 UI.Tracks.Scene.List.scrollToTop 717 1041 ··· 757 1081 Nothing -> 758 1082 andThen (Common.toggleLoadingScreen Off) 759 1083 ) 1084 + 1085 + 1086 + 1087 + -- ⚗️ ░░ COVERS 1088 + 1089 + 1090 + coverGroup : SortBy -> IdentifiedTrack -> String 1091 + coverGroup sort ( identifiers, { tags } as track ) = 1092 + (case sort of 1093 + Artist -> 1094 + tags.artist 1095 + 1096 + Album -> 1097 + -- There is the possibility of albums with the same name, 1098 + -- such as "Greatests Hits". 1099 + -- To make sure we treat those as different albums, 1100 + -- we prefix the album by its parent directory. 1101 + identifiers.parentDirectory ++ tags.album 1102 + 1103 + PlaylistIndex -> 1104 + "" 1105 + 1106 + Title -> 1107 + tags.title 1108 + ) 1109 + |> String.trim 1110 + |> String.toLower 1111 + 1112 + 1113 + coverKey : Bool -> Track -> String 1114 + coverKey isVariousArtists { tags } = 1115 + if isVariousArtists then 1116 + tags.album 1117 + 1118 + else 1119 + tags.artist ++ " --- " ++ tags.album 1120 + 1121 + 1122 + makeCover sortBy_ gathering collection previouslySelectedCover = 1123 + let 1124 + closedGathering = 1125 + { gathering 1126 + | largestAlbumSequence = 1127 + resolveLargestSequence 1128 + gathering.currentAlbumSequence 1129 + gathering.largestAlbumSequence 1130 + 1131 + -- 1132 + , largestAlbumFavsSequence = 1133 + resolveLargestSequence 1134 + gathering.currentAlbumFavsSequence 1135 + gathering.largestAlbumFavsSequence 1136 + 1137 + -- 1138 + , largestArtistSequence = 1139 + resolveLargestSequence 1140 + gathering.currentArtistSequence 1141 + gathering.largestArtistSequence 1142 + } 1143 + in 1144 + case closedGathering.acc of 1145 + [] -> 1146 + { collection = collection 1147 + , selectedCover = closedGathering.selectedCover 1148 + } 1149 + 1150 + fallback :: _ -> 1151 + let 1152 + cover = 1153 + makeCoverWithFallback sortBy_ closedGathering fallback 1154 + in 1155 + { collection = cover :: collection 1156 + , selectedCover = 1157 + case ( previouslySelectedCover, closedGathering.selectedCover ) of 1158 + ( Nothing, _ ) -> 1159 + Nothing 1160 + 1161 + ( Just _, Just _ ) -> 1162 + closedGathering.selectedCover 1163 + 1164 + ( Just sc, Nothing ) -> 1165 + case sortBy_ of 1166 + Artist -> 1167 + if cover.group == sc.group then 1168 + Just cover 1169 + 1170 + else 1171 + Nothing 1172 + 1173 + _ -> 1174 + if cover.key == sc.key then 1175 + Just cover 1176 + 1177 + else 1178 + Nothing 1179 + } 1180 + 1181 + 1182 + makeCoverWithFallback sortBy_ gathering fallback = 1183 + let 1184 + amountOfTracks = 1185 + List.length gathering.accIds 1186 + 1187 + group = 1188 + gathering.previousGroup 1189 + 1190 + identifiedTrack = 1191 + gathering.largestAlbumFavsSequence 1192 + |> Maybe.orElse gathering.largestAlbumSequence 1193 + |> Maybe.map Tuple.first 1194 + |> Maybe.withDefault fallback 1195 + 1196 + ( identifiers, track ) = 1197 + identifiedTrack 1198 + 1199 + ( largestAlbumSequence, largestArtistSequence ) = 1200 + ( Maybe.unwrap 0 Tuple.second gathering.largestAlbumSequence 1201 + , Maybe.unwrap 0 Tuple.second gathering.largestArtistSequence 1202 + ) 1203 + 1204 + ( sameAlbum, sameArtist ) = 1205 + ( largestAlbumSequence == amountOfTracks 1206 + , largestArtistSequence == amountOfTracks 1207 + ) 1208 + 1209 + isVariousArtists = 1210 + False 1211 + || (amountOfTracks > 4 && largestArtistSequence < 3) 1212 + || (String.toLower track.tags.artist == "va") 1213 + in 1214 + { key = Base64.encode (coverKey isVariousArtists track) 1215 + , identifiedTrackCover = identifiedTrack 1216 + 1217 + -- 1218 + , focus = 1219 + case sortBy_ of 1220 + Artist -> 1221 + "artist" 1222 + 1223 + _ -> 1224 + "album" 1225 + 1226 + -- 1227 + , group = group 1228 + , sameAlbum = sameAlbum 1229 + , sameArtist = sameArtist 1230 + 1231 + -- 1232 + , trackIds = gathering.accIds 1233 + , tracks = gathering.acc 1234 + , variousArtists = isVariousArtists 1235 + } 1236 + 1237 + 1238 + 1239 + -- ⚗️ ░░ COVERS → SEQUENCES 1240 + 1241 + 1242 + increaseSequence = 1243 + Maybe.map (Tuple.mapSecond ((+) 1)) 1244 + 1245 + 1246 + resolveLargestSequence curr state = 1247 + case ( curr, state ) of 1248 + ( Just ( _, c ), Just ( _, s ) ) -> 1249 + ifThenElse (c > s) curr state 1250 + 1251 + ( Just _, Nothing ) -> 1252 + curr 1253 + 1254 + ( Nothing, Just _ ) -> 1255 + state 1256 + 1257 + ( Nothing, Nothing ) -> 1258 + Nothing
+10 -8
src/Applications/UI/Tracks/Types.elm
··· 8 8 9 9 10 10 11 - -- 🌳 12 - 13 - 14 - type Scene 15 - = List 16 - 17 - 18 - 19 11 -- 📣 20 12 21 13 ··· 35 27 | RemoveFromCache (List Track) 36 28 | StoreInCache (List Track) 37 29 | StoredInCache Json.Value (Maybe String) 30 + --------- 31 + -- Covers 32 + --------- 33 + | GotCachedCover Json.Value 34 + | InsertCoverCache Json.Value 38 35 ----------------------------------------- 39 36 -- Collection 40 37 ----------------------------------------- ··· 51 48 ----------------------------------------- 52 49 -- Menus 53 50 ----------------------------------------- 51 + | ShowCoverMenu Cover Coordinates 52 + | ShowCoverMenuWithSmallDelay Cover Coordinates 54 53 | ShowTracksMenu (Maybe Int) { alt : Bool } Coordinates 55 54 | ShowTracksMenuWithSmallDelay (Maybe Int) { alt : Bool } Coordinates 56 55 | ShowViewMenu (Maybe Grouping) Mouse.Event 57 56 ----------------------------------------- 58 57 -- Scenes 59 58 ----------------------------------------- 59 + | ChangeScene Scene 60 + | DeselectCover 60 61 | InfiniteListMsg InfiniteList.Model 62 + | SelectCover Cover 61 63 ----------------------------------------- 62 64 -- Search 63 65 -----------------------------------------
+98 -60
src/Applications/UI/Tracks/View.elm
··· 8 8 import Coordinates exposing (Viewport) 9 9 import Css.Classes as C 10 10 import Html exposing (Html, text) 11 - import Html.Attributes exposing (href, placeholder, style, tabindex, target, title, value) 11 + import Html.Attributes exposing (attribute, href, placeholder, style, tabindex, target, title, value) 12 12 import Html.Events exposing (onBlur, onClick, onInput) 13 13 import Html.Events.Extra.Mouse as Mouse 14 14 import Html.Ext exposing (onEnterKey) ··· 24 24 import Tracks.Collection exposing (..) 25 25 import UI.Kit 26 26 import UI.Navigation exposing (..) 27 - import UI.Page 27 + import UI.Page as Page 28 28 import UI.Playlists.Page 29 29 import UI.Queue.Page 30 30 import UI.Sources.Page as Sources 31 + import UI.Tracks.Scene.Covers 31 32 import UI.Tracks.Scene.List 32 33 import UI.Tracks.Types exposing (..) 33 34 import UI.Types as UI exposing (..) ··· 37 38 -- 🗺 38 39 39 40 40 - type alias Dependencies = 41 - { amountOfSources : Int 42 - , bgColor : Maybe Color 43 - , darkMode : Bool 44 - , isOnIndexPage : Bool 45 - , isTouchDevice : Bool 46 - , sourceIdsBeingProcessed : List String 47 - , viewport : Viewport 48 - } 49 - 50 - 51 - view : Model -> Dependencies -> Html UI.Msg 52 - view model deps = 41 + view : Model -> Html UI.Msg 42 + view model = 43 + let 44 + isOnIndexPage = 45 + model.page == Page.Index 46 + in 53 47 chunk 54 48 viewClasses 55 - [ lazy6 49 + [ lazy7 56 50 navigation 57 51 model.grouping 58 52 model.favouritesOnly 59 53 model.searchTerm 60 54 model.selectedPlaylist 61 - deps.isOnIndexPage 62 - deps.bgColor 55 + isOnIndexPage 56 + model.extractedBackdropColor 57 + model.scene 63 58 64 59 -- 65 60 , if List.isEmpty model.tracks.harvested then 66 61 lazy4 67 62 noTracksView 68 - deps.sourceIdsBeingProcessed 69 - deps.amountOfSources 63 + (List.map Tuple.first model.processingContext) 64 + (List.length model.sources) 70 65 (List.length model.tracks.harvested) 71 66 (List.length model.favourites) 72 67 73 68 else 74 69 case model.scene of 70 + Covers -> 71 + UI.Tracks.Scene.Covers.view 72 + { bgColor = model.extractedBackdropColor 73 + , cachedCovers = model.cachedCovers 74 + , covers = model.covers 75 + , darkMode = model.darkMode 76 + , favouritesOnly = model.favouritesOnly 77 + , infiniteList = model.infiniteList 78 + , isVisible = isOnIndexPage 79 + , nowPlaying = model.nowPlaying 80 + , selectedCover = model.selectedCover 81 + , selectedTrackIndexes = model.selectedTrackIndexes 82 + , sortBy = model.sortBy 83 + , sortDirection = model.sortDirection 84 + , viewportHeight = model.viewport.height 85 + , viewportWidth = model.viewport.width 86 + } 87 + 75 88 List -> 76 - listView model deps 89 + model.selectedPlaylist 90 + |> Maybe.map .autoGenerated 91 + |> Maybe.andThen 92 + (\bool -> 93 + if bool then 94 + Nothing 95 + 96 + else 97 + Just model.dnd 98 + ) 99 + |> UI.Tracks.Scene.List.view 100 + { bgColor = model.extractedBackdropColor 101 + , darkMode = model.darkMode 102 + , height = model.viewport.height 103 + , isTouchDevice = model.isTouchDevice 104 + , isVisible = isOnIndexPage 105 + , showAlbum = model.viewport.width >= 720 106 + } 107 + model.tracks.harvested 108 + model.infiniteList 109 + model.favouritesOnly 110 + model.nowPlaying 111 + model.searchTerm 112 + model.sortBy 113 + model.sortDirection 114 + model.selectedTrackIndexes 77 115 ] 78 116 79 117 ··· 85 123 ] 86 124 87 125 88 - navigation : Maybe Grouping -> Bool -> Maybe String -> Maybe Playlist -> Bool -> Maybe Color -> Html UI.Msg 89 - navigation maybeGrouping favouritesOnly searchTerm selectedPlaylist isOnIndexPage bgColor = 126 + navigation : Maybe Grouping -> Bool -> Maybe String -> Maybe Playlist -> Bool -> Maybe Color -> Scene -> Html UI.Msg 127 + navigation maybeGrouping favouritesOnly searchTerm selectedPlaylist isOnIndexPage bgColor scene = 90 128 let 91 129 tabindex_ = 92 130 ifThenElse isOnIndexPage 0 -1 ··· 200 238 ] 201 239 202 240 -- 3 241 + , case scene of 242 + Covers -> 243 + brick 244 + [ attribute "title" "Switch to list view" 245 + , List 246 + |> ChangeScene 247 + |> TracksMsg 248 + |> onClick 249 + ] 250 + [ C.ml_1, C.mr_px, C.cursor_pointer ] 251 + [ chunk 252 + [ C.pl_1 ] 253 + [ Icons.notes 18 Inherit ] 254 + ] 255 + 256 + List -> 257 + brick 258 + [ attribute "title" "Switch to cover view" 259 + , Covers 260 + |> ChangeScene 261 + |> TracksMsg 262 + |> onClick 263 + ] 264 + [ C.ml_1, C.mr_px, C.cursor_pointer ] 265 + [ chunk 266 + [ C.pl_1 ] 267 + [ Icons.burst_mode 20 Inherit ] 268 + ] 269 + 270 + -- 4 203 271 , brick 204 272 [ Mouse.onClick (TracksMsg << ShowViewMenu maybeGrouping) 205 273 , title "View settings" ··· 209 277 ] 210 278 [ Icons.more_vert 16 Inherit ] 211 279 212 - -- 4 280 + -- 5 213 281 , case selectedPlaylist of 214 282 Just playlist -> 215 283 brick ··· 255 323 tabindex_ 256 324 [ ( Icon Icons.waves 257 325 , Label "Playlists" Hidden 258 - , NavigateToPage (UI.Page.Playlists UI.Playlists.Page.Index) 326 + , NavigateToPage (Page.Playlists UI.Playlists.Page.Index) 259 327 ) 260 328 , ( Icon Icons.schedule 261 329 , Label "Queue" Hidden 262 - , NavigateToPage (UI.Page.Queue UI.Queue.Page.Index) 330 + , NavigateToPage (Page.Queue UI.Queue.Page.Index) 263 331 ) 264 332 , ( Icon Icons.equalizer 265 333 , Label "Equalizer" Hidden 266 - , NavigateToPage UI.Page.Equalizer 334 + , NavigateToPage Page.Equalizer 267 335 ) 268 336 ] 269 337 ] ··· 272 340 noTracksView : List String -> Int -> Int -> Int -> Html UI.Msg 273 341 noTracksView processingContext amountOfSources amountOfTracks amountOfFavourites = 274 342 chunk 275 - [ C.flex, C.flex_grow ] 343 + [ "no-tracks-view", C.flex, C.flex_grow ] 276 344 [ UI.Kit.centeredContent 277 345 [ if List.length processingContext > 0 then 278 346 message "Processing Tracks" ··· 291 359 [ C.mb_3, C.mx_2, C.whitespace_no_wrap ] 292 360 [ UI.Kit.buttonLink 293 361 (Sources.NewOnboarding 294 - |> UI.Page.Sources 295 - |> UI.Page.toString 362 + |> Page.Sources 363 + |> Page.toString 296 364 ) 297 365 UI.Kit.Filled 298 366 (buttonContents ··· 366 434 , C.pb_1 367 435 ] 368 436 [ text m ] 369 - 370 - 371 - listView : Model -> Dependencies -> Html UI.Msg 372 - listView model deps = 373 - model.selectedPlaylist 374 - |> Maybe.map .autoGenerated 375 - |> Maybe.andThen 376 - (\bool -> 377 - if bool then 378 - Nothing 379 - 380 - else 381 - Just model.dnd 382 - ) 383 - |> UI.Tracks.Scene.List.view 384 - { bgColor = deps.bgColor 385 - , darkMode = deps.darkMode 386 - , height = deps.viewport.height 387 - , isTouchDevice = deps.isTouchDevice 388 - , isVisible = deps.isOnIndexPage 389 - , showAlbum = deps.viewport.width >= 720 390 - } 391 - model.tracks.harvested 392 - model.infiniteList 393 - model.favouritesOnly 394 - model.nowPlaying 395 - model.searchTerm 396 - model.sortBy 397 - model.sortDirection 398 - model.selectedTrackIndexes
+4 -1
src/Applications/UI/Types.elm
··· 35 35 import UI.Page as Page exposing (Page) 36 36 import UI.Queue.Types as Queue 37 37 import UI.Sources.Types as Sources 38 - import UI.Tracks.Types as Tracks exposing (Scene) 38 + import UI.Tracks.Types as Tracks 39 39 import Url exposing (Protocol(..), Url) 40 40 import User.Layer exposing (..) 41 41 ··· 150 150 ----------------------------------------- 151 151 -- Tracks 152 152 ----------------------------------------- 153 + , cachedCovers : Maybe (Dict String String) 153 154 , cachedTracks : List String 154 155 , cachedTracksOnly : Bool 155 156 , cachingTracksInProgress : List String 157 + , covers : List Tracks.Cover 156 158 , favourites : List Favourite 157 159 , favouritesOnly : Bool 158 160 , grouping : Maybe Grouping ··· 160 162 , scene : Scene 161 163 , searchResults : Maybe (List String) 162 164 , searchTerm : Maybe String 165 + , selectedCover : Maybe Cover 163 166 , selectedTrackIndexes : List Int 164 167 , sortBy : SortBy 165 168 , sortDirection : SortDirection
+1
src/Applications/UI/User/State/Export.elm
··· 50 50 , onlyShowCachedTracks = model.cachedTracksOnly 51 51 , onlyShowFavourites = model.favouritesOnly 52 52 , repeat = model.repeat 53 + , scene = model.scene 53 54 , searchTerm = model.searchTerm 54 55 , selectedPlaylist = Maybe.map .name model.selectedPlaylist 55 56 , shuffle = model.shuffle
+2
src/Applications/UI/User/State/Import.elm
··· 23 23 import UI.Ports as Ports 24 24 import UI.Sources.State as Sources 25 25 import UI.Tracks.State as Tracks 26 + import UI.Tracks.Types as Tracks 26 27 import UI.Types as UI exposing (..) 27 28 import UI.User.State.Export as User 28 29 import Url.Ext as Url ··· 268 269 , cachedTracksOnly = data.onlyShowCachedTracks 269 270 , favouritesOnly = data.onlyShowFavourites 270 271 , grouping = data.grouping 272 + , scene = data.scene 271 273 , searchTerm = data.searchTerm 272 274 , sortBy = data.sortBy 273 275 , sortDirection = data.sortDirection
+2 -11
src/Applications/UI/View.elm
··· 152 152 -- Main 153 153 ----------------------------------------- 154 154 , vessel 155 - [ Tracks.view 156 - model 157 - { amountOfSources = List.length model.sources 158 - , bgColor = model.extractedBackdropColor 159 - , darkMode = model.darkMode 160 - , isOnIndexPage = model.page == Page.Index 161 - , isTouchDevice = model.isTouchDevice 162 - , sourceIdsBeingProcessed = List.map Tuple.first model.processingContext 163 - , viewport = model.viewport 164 - } 155 + [ Tracks.view model 165 156 166 157 -- Pages 167 158 -------- ··· 285 276 Maybe.isJust maybeAlfred || Maybe.isJust maybeContextMenu 286 277 in 287 278 brick 288 - [ onClick HideOverlay ] 279 + [] 289 280 [ C.inset_0 290 281 , C.bg_black 291 282 , C.duration_1000
+12
src/Css/Application.css
··· 158 158 stroke-dashoffset: -86.25; 159 159 } 160 160 } 161 + 162 + 163 + 164 + /* Overrides 165 + --------- */ 166 + 167 + .no-tracks-view > div { 168 + /* CSS fix for a scrolling issue: 169 + Element would appear scrolled even though it's not (virtual-dom issue probably) 170 + */ 171 + overflow: visible !important; 172 + }
+104
src/Javascript/Brain/artwork.js
··· 1 + // 2 + // Album Covers 3 + // (◕‿◕✿) 4 + 5 + 6 + import * as db from "../indexed-db" 7 + import * as processing from "../processing" 8 + 9 + import { toCache } from "./common" 10 + 11 + 12 + const REJECT = () => Promise.reject("No artwork found") 13 + 14 + 15 + export function find(prep) { 16 + prep.variousArtists = prep.variousArtists === "t" 17 + 18 + return findUsingTags(prep) 19 + .then(a => a ? a : findUsingMusicBrainz(prep)) 20 + .then(a => a ? a : findUsingLastFm(prep)) 21 + .then(a => a ? a : REJECT()) 22 + .then(a => a.type.startsWith("image/") ? a : REJECT()) 23 + } 24 + 25 + 26 + 27 + // 1. TAGS 28 + 29 + 30 + function findUsingTags(prep) { 31 + return processing.getTags( 32 + prep.trackHeadUrl, 33 + prep.trackGetUrl, 34 + prep.trackFilename, 35 + { skipCovers: false } 36 + 37 + ).then(tags => { 38 + return tags.picture 39 + ? new Blob([ tags.picture.data ], { type: tags.picture.format }) 40 + : null 41 + 42 + }) 43 + } 44 + 45 + 46 + 47 + // 2. MUSIC BRAINZ 48 + 49 + 50 + function findUsingMusicBrainz(prep) { 51 + const parts = atob(prep.cacheKey).split(" --- ") 52 + const artist = parts[0] 53 + const album = parts[1] || parts[0] 54 + 55 + const query = `release:"${album}"` + (prep.variousArtists ? `` : ` AND artist:"${artist}"`) 56 + const encodedQuery = encodeURIComponent(query) 57 + 58 + return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 59 + .then(r => r.json()) 60 + .then(r => musicBrainzCover(r.releases)) 61 + } 62 + 63 + 64 + function musicBrainzCover(remainingReleases) { 65 + const release = remainingReleases[0] 66 + if (!release) return null 67 + 68 + return fetch( 69 + `https://coverartarchive.org/release/${release.id}/front-500` 70 + ).then( 71 + r => r.blob() 72 + ).then( 73 + r => r && r.type.startsWith("image/") 74 + ? r 75 + : musicBrainzCover(remainingReleases.slice(1)) 76 + ).catch( 77 + e => musicBrainzCover(remainingReleases.slice(1)) 78 + ) 79 + } 80 + 81 + 82 + 83 + // 3. LAST FM 84 + 85 + 86 + function findUsingLastFm(prep) { 87 + const query = atob(prep.cacheKey).replace(" --- ", " ") 88 + 89 + return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) 90 + .then(r => r.json()) 91 + .then(r => lastFmCover(r.results.albummatches.album)) 92 + } 93 + 94 + 95 + function lastFmCover(remainingMatches) { 96 + const album = remainingMatches[0] 97 + const url = album ? album.image[album.image.length - 1]["#text"] : null 98 + 99 + return url && url !== "" 100 + ? fetch(url) 101 + .then(r => r.blob()) 102 + .catch(_ => lastFmCover(remainingMatches.slice(1))) 103 + : album && lastFmCover(remainingMatches.slice(1)) 104 + }
+53 -3
src/Javascript/Brain/index.js
··· 5 5 // This worker is responsible for everything non-UI. 6 6 7 7 8 + import * as artwork from "./artwork" 8 9 import * as db from "../indexed-db" 9 10 import * as processing from "../processing" 10 11 import * as user from "./user" ··· 43 44 // UI 44 45 // == 45 46 47 + app.ports.toUI.subscribe(event => { 48 + self.postMessage(event) 49 + }) 50 + 51 + 46 52 self.onmessage = event => { 53 + if (event.data.action) return handleAction(event.data.action, event.data.data) 47 54 if (event.data.tag) return app.ports.fromAlien.send(event.data) 48 55 } 49 56 50 57 51 - app.ports.toUI.subscribe(event => { 52 - self.postMessage(event) 53 - }) 58 + function handleAction(action, data) { switch (action) { 59 + case "DOWNLOAD_ARTWORK": return downloadArtwork(data) 60 + }} 54 61 55 62 56 63 ··· 82 89 toCache(key, event.data.data || event.data) 83 90 .then( storageCallback(app, event) ) 84 91 .catch( reportError(app, event) ) 92 + }) 93 + 94 + 95 + 96 + // Cache (Artwork) 97 + // --------------- 98 + 99 + let artworkQueue = [] 100 + 101 + 102 + function downloadArtwork(list) { 103 + const exe = !artworkQueue[0] 104 + artworkQueue = artworkQueue.concat(list) 105 + if (exe) shiftArtworkQueue() 106 + } 107 + 108 + 109 + function shiftArtworkQueue() { 110 + const next = artworkQueue.shift() 111 + if (next) app.ports.makeArtworkTrackUrls.send(next) 112 + } 113 + 114 + 115 + app.ports.provideArtworkTrackUrls.subscribe(prep => { 116 + artwork 117 + .find(prep) 118 + .then(blob => { 119 + const url = URL.createObjectURL(blob) 120 + 121 + toCache(`coverCache.${prep.cacheKey}`, blob) 122 + 123 + self.postMessage({ 124 + tag: "GOT_CACHED_COVER", 125 + data: { key: prep.cacheKey, url: url }, 126 + error: null 127 + }) 128 + }) 129 + .catch(_ => { 130 + // Indicate that we've tried to find artwork, 131 + // so that we don't try to find it each time we launch the app. 132 + toCache(`coverCache.${prep.cacheKey}`, "TRIED") 133 + }) 134 + .finally(shiftArtworkQueue) 85 135 }) 86 136 87 137
+85
src/Javascript/index.js
··· 13 13 import "../../build/vendor/pep" 14 14 15 15 import * as audioEngine from "./audio-engine" 16 + import * as db from "./indexed-db" 16 17 import { debounce, fileExtension } from "./common" 17 18 18 19 ··· 32 33 } 33 34 } 34 35 }) 36 + 35 37 36 38 self.app = app 37 39 ··· 227 229 document.getSelection().addRange(selected) 228 230 } 229 231 232 + }) 233 + 234 + 235 + 236 + // Covers 237 + // ------ 238 + 239 + const loadingCovers = {} 240 + 241 + 242 + app.ports.loadAlbumCovers.subscribe( 243 + debounce(loadAlbumCovers, 500) 244 + ) 245 + 246 + 247 + function loadAlbumCovers() { 248 + const nodes = Array.from( 249 + document.querySelectorAll("#diffuse__track-covers [data-key]") 250 + ) 251 + 252 + if (!nodes.length) return; 253 + 254 + const artworkPrep = nodes.map(node => { 255 + return { 256 + cacheKey: node.getAttribute("data-key"), 257 + focus: node.getAttribute("data-focus"), 258 + trackFilename: node.getAttribute("data-filename"), 259 + trackPath: node.getAttribute("data-path"), 260 + trackSourceId: node.getAttribute("data-source-id"), 261 + variousArtists: node.getAttribute("data-various-artists") 262 + } 263 + 264 + }).filter(prep => { 265 + return !loadingCovers[prep.cacheKey] 266 + 267 + }) 268 + 269 + artworkPrep.forEach(prep => { 270 + loadingCovers[prep.cacheKey] = true 271 + }) 272 + 273 + artworkPrep.reduce((acc, prep) => { 274 + return acc.then(arr => { 275 + return db.getFromIndex({ key: `coverCache.${prep.cacheKey}` }).then(a => { 276 + if (!a) return arr.concat([ prep ]) 277 + return arr 278 + }) 279 + }) 280 + 281 + }, Promise.resolve([])).then(withoutEarlierAttempts => { 282 + brain.postMessage({ 283 + action: "DOWNLOAD_ARTWORK", 284 + data: withoutEarlierAttempts 285 + }) 286 + 287 + }) 288 + } 289 + 290 + 291 + // Send a dictionary of the cached covers to the app. 292 + db.keys().then(keys => { 293 + const cacheKeys = keys.filter( 294 + k => k.startsWith("coverCache.") 295 + ) 296 + 297 + const cachePromise = cacheKeys.reduce((acc, key) => { 298 + return acc.then(cache => { 299 + return db.getFromIndex({ key: key }).then(blob => { 300 + const cacheKey = key.slice(11) 301 + 302 + if (blob && typeof blob !== "string") { 303 + cache[cacheKey] = URL.createObjectURL(blob) 304 + } 305 + 306 + return cache 307 + }) 308 + }) 309 + }, Promise.resolve({})) 310 + 311 + cachePromise.then(cache => { 312 + app.ports.insertCoverCache.send(cache) 313 + setTimeout(loadAlbumCovers, 500) 314 + }) 230 315 }) 231 316 232 317
+49 -19
src/Javascript/indexed-db.js
··· 6 6 // This is used instead of localStorage. 7 7 8 8 9 + import delay from "delay" 10 + import retryPromise from "p-retry" 11 + 12 + 13 + const WAITING_MSG = "Waiting for database" 14 + 15 + 9 16 self.importScripts && importScripts("version.js") 10 17 11 18 ··· 49 56 // Get 50 57 // --- 51 58 52 - export function getFromIndex(args) { 53 - if (!db && tries < 20) { 54 - tries++ 55 - 56 - return new Promise((resolve, reject) => { 57 - setTimeout( 58 - () => { getFromIndex(args).then(resolve, reject) }, 59 - 250 60 - ) 61 - }) 62 - } 59 + export const getFromIndex = args => retry(() => { 60 + return new Promise((resolve, reject) => { 61 + if (!db) throw new Error(WAITING_MSG) 63 62 64 - tries = 0 65 - 66 - return new Promise((resolve, reject) => { 67 63 const sto = args.store || storeNames.main 68 64 const key = args.key 69 - const tra = db.transaction([sto], "readwrite") 65 + const tra = db.transaction([sto], "readonly") 70 66 const req = tra.objectStore(sto).get(key) 71 67 72 68 req.onsuccess = _ => { ··· 79 75 80 76 req.onerror = reject 81 77 }) 82 - } 78 + }) 79 + 80 + 81 + export const keys = args => retry(() => { 82 + return new Promise((resolve, reject) => { 83 + if (!db) throw new Error(WAITING_MSG) 84 + 85 + const sto = (args || {}).store || storeNames.main 86 + const tra = db.transaction([sto], "readonly") 87 + const req = tra.objectStore(sto).getAllKeys() 88 + 89 + req.onsuccess = _ => { 90 + if (req.result) { 91 + resolve(req.result) 92 + } else { 93 + resolve(null) 94 + } 95 + } 96 + 97 + req.onerror = reject 98 + }) 99 + }) 83 100 84 101 85 102 86 103 // Set 87 104 // --- 88 105 89 - export function setInIndex(args) { 106 + export const setInIndex = args => retry(() => { 90 107 return new Promise((resolve, reject) => { 108 + if (!db) throw new Error(WAITING_MSG) 109 + 91 110 const sto = args.store || storeNames.main 92 111 const key = args.key 93 112 const dat = args.data ··· 98 117 req.onsuccess = resolve 99 118 req.onerror = reject 100 119 }) 101 - } 120 + }) 102 121 103 122 104 123 105 124 // Delete 106 125 // ------ 107 126 108 - export function deleteFromIndex(args) { 127 + export const deleteFromIndex = args => retry(() => { 109 128 return new Promise((resolve, reject) => { 129 + if (!db) throw new Error(WAITING_MSG) 130 + 110 131 const sto = args.store || storeNames.main 111 132 const key = args.key 112 133 const tra = db.transaction([sto], "readwrite") ··· 115 136 req.onsuccess = resolve 116 137 req.onerror = reject 117 138 }) 139 + }) 140 + 141 + 142 + 143 + // ⚗️ 144 + // -- 145 + 146 + function retry(func) { 147 + return retryPromise(func, { onFailedAttempt: _ => delay(250), retries: 20 }) 118 148 }
+5 -5
src/Javascript/processing.js
··· 30 30 transformUrl(urls.getUrl) 31 31 32 32 ]).then(([headUrl, getUrl]) => { 33 - return getTags(headUrl, getUrl, filename) 33 + return getTags(headUrl, getUrl, filename, { skipCovers: true }) 34 34 35 35 }).then(r => { 36 36 return col.concat(r) ··· 57 57 58 58 const parserConfiguration = Object.assign( 59 59 {}, musicMetadata.parsingOptions, 60 - { duration: false, skipCovers: true, skipPostHeaders: true } 60 + { duration: false, skipPostHeaders: true } 61 61 ) 62 62 63 63 64 - function getTags(headUrl, getUrl, filename) { 64 + export function getTags(headUrl, getUrl, filename, options) { 65 65 const fileExtMatch = filename.match(/\.(\w+)$/) 66 66 const fileExt = fileExtMatch && fileExtMatch[1] 67 67 ··· 91 91 92 92 return musicMetadata.parseFromTokenizer( 93 93 tokenizer, 94 - parserConfiguration 94 + Object.assign({}, parserConfiguration, options || {}) 95 95 ) 96 96 }) 97 97 .then(pickTags) ··· 114 114 title: tags.title && tags.title.length ? tags.title : "Unknown", 115 115 genre: (tags.genre && tags.genre[0]) || null, 116 116 year: tags.year || null, 117 - picture: null 117 + picture: tags.picture ? tags.picture[0] : null 118 118 } 119 119 } 120 120
+2
src/Library/Alien.elm
··· 65 65 | AddTracks 66 66 | FinishedProcessingSource 67 67 | FinishedProcessingSources 68 + | GotCachedCover 68 69 | HideLoadingScreen 69 70 | LoadEnclosedUserData 70 71 | LoadHypaethralUserData ··· 122 123 , ( "ADD_TRACKS", AddTracks ) 123 124 , ( "FINISHED_PROCESSING_SOURCE", FinishedProcessingSource ) 124 125 , ( "FINISHED_PROCESSING_SOURCES", FinishedProcessingSources ) 126 + , ( "GOT_CACHED_COVER", GotCachedCover ) 125 127 , ( "HIDE_LOADING_SCREEN", HideLoadingScreen ) 126 128 , ( "LOAD_ENCLOSED_USER_DATA", LoadEnclosedUserData ) 127 129 , ( "LOAD_HYPAETHRAL_USER_DATA", LoadHypaethralUserData )
+54 -1
src/Library/Tracks.elm
··· 1 - module Tracks exposing (Collection, CollectionDependencies, Favourite, Grouping(..), IdentifiedTrack, Identifiers, Parcel, SortBy(..), SortDirection(..), Tags, Track, emptyCollection, emptyIdentifiedTrack, emptyIdentifiers, emptyTags, emptyTrack, isNowPlaying, makeTrack, missingId, pick, removeByPaths, removeBySourceId, removeFromPlaylist, toPlaylistTracks) 1 + module Tracks exposing (..) 2 2 3 3 import Base64 4 4 import List.Extra as List ··· 45 45 -- DERIVATIVES & SUPPLEMENTS 46 46 47 47 48 + type alias Cover = 49 + { focus : String 50 + , group : String 51 + , identifiedTrackCover : IdentifiedTrack 52 + , key : String 53 + , sameAlbum : Bool 54 + , sameArtist : Bool 55 + , trackIds : List String 56 + , tracks : List IdentifiedTrack 57 + , variousArtists : Bool 58 + } 59 + 60 + 48 61 type alias Favourite = 49 62 { artist : String 50 63 , title : String ··· 60 73 , isMissing : Bool 61 74 62 75 -- 76 + , filename : String 63 77 , group : Maybe { name : String, firstInGroup : Bool } 64 78 , indexInList : Int 65 79 , indexInPlaylist : Maybe Int 80 + , parentDirectory : String 66 81 } 67 82 68 83 ··· 131 146 132 147 133 148 149 + -- VIEW 150 + 151 + 152 + type Scene 153 + = Covers 154 + | List 155 + 156 + 157 + 134 158 -- 🔱 135 159 136 160 ··· 170 194 , isMissing = False 171 195 172 196 -- 197 + , filename = "" 173 198 , group = Nothing 174 199 , indexInList = 0 175 200 , indexInPlaylist = Nothing 201 + , parentDirectory = "" 176 202 } 177 203 178 204 ··· 211 237 } 212 238 213 239 240 + pathParts : Track -> { filename : String, parentDirectory : String } 241 + pathParts { path } = 242 + let 243 + s = 244 + String.split "/" path 245 + 246 + l = 247 + List.length s 248 + in 249 + case List.drop (max 0 <| l - 2) s of 250 + [ p, f ] -> 251 + { filename = f, parentDirectory = p } 252 + 253 + [ f ] -> 254 + { filename = f, parentDirectory = "" } 255 + 256 + _ -> 257 + { filename = "", parentDirectory = "" } 258 + 259 + 214 260 {-| Given a collection of tracks, pick out the tracks by id in order. 215 261 Note that track ids in the ids list may occur multiple times. 216 262 -} ··· 301 347 missingId : String 302 348 missingId = 303 349 "<missing>" 350 + 351 + 352 + shouldRenderGroup : Identifiers -> Bool 353 + shouldRenderGroup identifiers = 354 + identifiers.group 355 + |> Maybe.map (.firstInGroup >> (==) True) 356 + |> Maybe.withDefault False 304 357 305 358 306 359 toPlaylistTracks : List IdentifiedTrack -> List PlaylistTrack
+4 -4
src/Library/Tracks/Collection.elm
··· 1 - module Tracks.Collection exposing (add, arrange, harvest, harvestChanged, identify, map, tracksChanged) 1 + module Tracks.Collection exposing (add, arrange, harvest, identifiedTracksChanged, identify, map, tracksChanged) 2 2 3 3 import Tracks exposing (IdentifiedTrack, Parcel, Track, emptyCollection) 4 4 import Tracks.Collection.Internal as Internal ··· 67 67 True 68 68 69 69 70 - harvestChanged : List IdentifiedTrack -> List IdentifiedTrack -> Bool 71 - harvestChanged listA listB = 70 + identifiedTracksChanged : List IdentifiedTrack -> List IdentifiedTrack -> Bool 71 + identifiedTracksChanged listA listB = 72 72 case ( listA, listB ) of 73 73 ( [], [] ) -> 74 74 False ··· 78 78 True 79 79 80 80 else 81 - harvestChanged restA restB 81 + identifiedTracksChanged restA restB 82 82 83 83 _ -> 84 84 True
+3 -1
src/Library/Tracks/Collection/Internal/Arrange.elm
··· 280 280 } 281 281 in 282 282 Tuple.pair 283 - { group = Nothing 283 + { filename = "" 284 + , group = Nothing 284 285 , indexInList = 0 285 286 , indexInPlaylist = Just identifiers.index 286 287 , isFavourite = False 287 288 , isMissing = True 289 + , parentDirectory = "" 288 290 } 289 291 { tags = tags 290 292 , id = missingId
+7
src/Library/Tracks/Collection/Internal/Identify.elm
··· 88 88 isFav = 89 89 List.any isFavourite_ favourites 90 90 91 + { filename, parentDirectory } = 92 + pathParts track 93 + 91 94 identifiedTrack = 92 95 ( { isFavourite = isFav 93 96 , isMissing = False 94 97 95 98 -- 99 + , filename = filename 96 100 , group = Nothing 97 101 , indexInList = 0 98 102 , indexInPlaylist = Nothing 103 + , parentDirectory = parentDirectory 99 104 } 100 105 , track 101 106 ) ··· 148 153 , isMissing = True 149 154 150 155 -- 156 + , filename = "" 151 157 , group = Nothing 152 158 , indexInList = 0 153 159 , indexInPlaylist = Nothing 160 + , parentDirectory = "" 154 161 } 155 162 , { tags = tags 156 163 , id = missingId
+28 -1
src/Library/Tracks/Encoding.elm
··· 1 - module Tracks.Encoding exposing (decodeFavourite, decodeTrack, encodeFavourite, encodeGrouping, encodeMaybe, encodeSortBy, encodeSortDirection, encodeTags, encodeTrack, favouriteDecoder, groupingDecoder, sortByDecoder, sortDirectionDecoder, tagsDecoder, trackDecoder) 1 + module Tracks.Encoding exposing (..) 2 2 3 3 import Json.Decode as Decode 4 4 import Json.Decode.Pipeline exposing (optional, required) ··· 33 33 34 34 TrackYear -> 35 35 Encode.string "TRACK_YEAR" 36 + 37 + 38 + encodeScene : Scene -> Encode.Value 39 + encodeScene scene = 40 + case scene of 41 + Covers -> 42 + Encode.string "COVERS" 43 + 44 + List -> 45 + Encode.string "LIST" 36 46 37 47 38 48 encodeSortBy : SortBy -> Encode.Value ··· 139 149 140 150 _ -> 141 151 Decode.fail "Invalid Grouping" 152 + ) 153 + Decode.string 154 + 155 + 156 + sceneDecoder : Decode.Decoder Scene 157 + sceneDecoder = 158 + Decode.andThen 159 + (\string -> 160 + case string of 161 + "COVERS" -> 162 + Decode.succeed Covers 163 + 164 + "LIST" -> 165 + Decode.succeed List 166 + 167 + _ -> 168 + Decode.fail "Invalid Scene" 142 169 ) 143 170 Decode.string 144 171
+10 -3
src/Library/Tracks/Sorting.elm
··· 41 41 42 42 43 43 sortByAlbum : IdentifiedTrack -> IdentifiedTrack -> Order 44 - sortByAlbum ( _, a ) ( _, b ) = 44 + sortByAlbum ( x, a ) ( y, b ) = 45 45 EQ 46 46 |> andThenCompare album a b 47 + |> andThenCompare parentDir x y 47 48 |> andThenCompare disc a b 48 49 |> andThenCompare nr a b 49 50 |> andThenCompare artist a b ··· 51 52 52 53 53 54 sortByArtist : IdentifiedTrack -> IdentifiedTrack -> Order 54 - sortByArtist ( _, a ) ( _, b ) = 55 + sortByArtist ( x, a ) ( y, b ) = 55 56 EQ 56 57 |> andThenCompare artist a b 57 58 |> andThenCompare album a b 59 + |> andThenCompare parentDir x y 58 60 |> andThenCompare disc a b 59 61 |> andThenCompare nr a b 60 62 |> andThenCompare title a b ··· 102 104 .tags >> .nr 103 105 104 106 107 + parentDir : Identifiers -> String 108 + parentDir = 109 + .parentDirectory >> low 110 + 111 + 105 112 106 113 -- COMMON 107 114 ··· 117 124 118 125 low : String -> String 119 126 low = 120 - String.toLower 127 + String.trim >> String.toLower
+5 -2
src/Library/User/Layer.elm
··· 56 56 , onlyShowCachedTracks : Bool 57 57 , onlyShowFavourites : Bool 58 58 , repeat : Bool 59 + , scene : Tracks.Scene 59 60 , searchTerm : Maybe String 60 61 , selectedPlaylist : Maybe String 61 62 , shuffle : Bool ··· 188 189 |> optional "onlyShowCachedTracks" Json.bool False 189 190 |> optional "onlyShowFavourites" Json.bool False 190 191 |> optional "repeat" Json.bool False 192 + |> optional "scene" Tracks.sceneDecoder Tracks.List 191 193 |> optional "searchTerm" (Json.maybe Json.string) Nothing 192 194 |> optional "selectedPlaylist" (Json.maybe Json.string) Nothing 193 195 |> optional "shuffle" Json.bool False 194 - |> optional "sortBy" Tracks.sortByDecoder Tracks.Artist 196 + |> optional "sortBy" Tracks.sortByDecoder Tracks.Album 195 197 |> optional "sortDirection" Tracks.sortDirectionDecoder Tracks.Asc 196 198 197 199 198 200 encodeEnclosedData : EnclosedData -> Json.Value 199 - encodeEnclosedData { cachedTracks, equalizerSettings, grouping, onlyShowCachedTracks, onlyShowFavourites, repeat, searchTerm, selectedPlaylist, shuffle, sortBy, sortDirection } = 201 + encodeEnclosedData { cachedTracks, equalizerSettings, grouping, onlyShowCachedTracks, onlyShowFavourites, repeat, scene, searchTerm, selectedPlaylist, shuffle, sortBy, sortDirection } = 200 202 Json.Encode.object 201 203 [ ( "cachedTracks", Json.Encode.list Json.Encode.string cachedTracks ) 202 204 , ( "equalizerSettings", Equalizer.encodeSettings equalizerSettings ) ··· 204 206 , ( "onlyShowCachedTracks", Json.Encode.bool onlyShowCachedTracks ) 205 207 , ( "onlyShowFavourites", Json.Encode.bool onlyShowFavourites ) 206 208 , ( "repeat", Json.Encode.bool repeat ) 209 + , ( "scene", Tracks.encodeScene scene ) 207 210 , ( "searchTerm", Maybe.unwrap Json.Encode.null Json.Encode.string searchTerm ) 208 211 , ( "selectedPlaylist", Maybe.unwrap Json.Encode.null Json.Encode.string selectedPlaylist ) 209 212 , ( "shuffle", Json.Encode.bool shuffle )
+2
src/Static/About/About.md
··· 259 259 260 260 Left / Right - Previous / Next 261 261 Up / Down - Seek forwards / Seek backwards 262 + 263 + ESC - Close overlay, close context-menu, deselect album cover, etc. 262 264 ``` 263 265 264 266
+1
stack.yaml
··· 1 1 resolver: lts-13.5 2 + recommend-stack-upgrade: false
+1
system/Css/Tailwind.js
··· 106 106 "black_50": "rgba(0, 0, 0, 0.5)", 107 107 "current-color": "currentColor", 108 108 "inherit": "inherit", 109 + "white-025": "rgba(255, 255, 255, 0.025)", 109 110 "white-20": "rgba(255, 255, 255, 0.2)", 110 111 "white-60": "rgba(255, 255, 255, 0.6)", 111 112 "white-90": "rgba(255, 255, 255, 0.9)",
+23
yarn.lock
··· 142 142 dependencies: 143 143 "@types/node" "*" 144 144 145 + "@types/retry@^0.12.0": 146 + version "0.12.0" 147 + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" 148 + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== 149 + 145 150 "@webassemblyjs/ast@1.9.0": 146 151 version "1.9.0" 147 152 resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" ··· 1421 1426 version "1.0.0" 1422 1427 resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" 1423 1428 integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= 1429 + 1430 + delay@^4.3.0: 1431 + version "4.3.0" 1432 + resolved "https://registry.yarnpkg.com/delay/-/delay-4.3.0.tgz#efeebfb8f545579cb396b3a722443ec96d14c50e" 1433 + integrity sha512-Lwaf3zVFDMBop1yDuFZ19F9WyGcZcGacsbdlZtWjQmM50tOcMntm1njF/Nb/Vjij3KaSvCF+sEYGKrrjObu2NA== 1424 1434 1425 1435 delayed-stream@~1.0.0: 1426 1436 version "1.0.0" ··· 3739 3749 dependencies: 3740 3750 p-limit "^2.2.0" 3741 3751 3752 + p-retry@^4.2.0: 3753 + version "4.2.0" 3754 + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.2.0.tgz#ea9066c6b44f23cab4cd42f6147cdbbc6604da5d" 3755 + integrity sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA== 3756 + dependencies: 3757 + "@types/retry" "^0.12.0" 3758 + retry "^0.12.0" 3759 + 3742 3760 p-timeout@^2.0.1: 3743 3761 version "2.0.1" 3744 3762 resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" ··· 4451 4469 version "0.1.15" 4452 4470 resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" 4453 4471 integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== 4472 + 4473 + retry@^0.12.0: 4474 + version "0.12.0" 4475 + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" 4476 + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= 4454 4477 4455 4478 reusify@^1.0.0: 4456 4479 version "1.0.4"