diff --git a/gleam.toml b/gleam.toml index 9af4b61..6a7db88 100644 --- a/gleam.toml +++ b/gleam.toml @@ -13,13 +13,13 @@ gleam = ">= 0.32.0" [dependencies] gleam_stdlib = "~> 0.31" -lustre = "~> 3.0" gleam_json = "~> 0.6" gleam_http = "~> 3.5" gleam_javascript = "~> 0.6" gleam_fetch = "~> 0.2" plinth = "~> 0.1" varasto = "~> 1.0" +lustre = "~> 3.0" [dev-dependencies] gleeunit = "~> 0.10" diff --git a/manifest.toml b/manifest.toml index 937e3f1..78227cc 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,16 +2,16 @@ # You typically do not need to edit this file packages = [ - { name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_http", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" }, + { name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" }, { name = "gleam_http", version = "3.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0B09AAE8EB547C4F2F2D3F8917A0A4D2EF75DFF0232389332BAFE19DBBFDB92B" }, { name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" }, { name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" }, { name = "gleam_stdlib", version = "0.32.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "07D64C26D014CF570F8ACADCE602761EA2E74C842D26F2FD49B0D61973D9966F" }, { name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" }, - { name = "lustre", version = "3.0.10", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "DBA4A223D93BDDD43B9D8EEB52AE130A6995C8078559F32DB58BE89899307FED" }, + { name = "lustre", version = "3.0.11", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "4B46341D9A3426023CFA21A6CFF4ED0171652A01B8905D1878099308DA2E92D2" }, { name = "plinth", version = "0.1.4", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "43307A0271B8AC9B646B3C39F80D22C41DB4022CDAD67ABFAA555BB3F1321E2E" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, - { name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" }, + { name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["plinth", "gleam_stdlib", "gleam_json"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" }, ] [requirements] diff --git a/src/elekf/utils/set.gleam b/src/elekf/utils/set.gleam new file mode 100644 index 0000000..7984a10 --- /dev/null +++ b/src/elekf/utils/set.gleam @@ -0,0 +1,10 @@ +import gleam/set.{type Set} + +/// Toggle an item in a set. In other words, insert the item if it didn't +/// already exist, and remove it if it did. +pub fn toggle(set: Set(a), item: a) -> Set(a) { + case set.contains(set, item) { + True -> set.delete(set, item) + False -> set.insert(set, item) + } +} diff --git a/src/elekf/web/components/library_view.gleam b/src/elekf/web/components/library_view.gleam index 9bda60a..87f7df7 100644 --- a/src/elekf/web/components/library_view.gleam +++ b/src/elekf/web/components/library_view.gleam @@ -15,6 +15,7 @@ import lustre/event import elekf/library.{type Library} import elekf/library/track.{type Track} import elekf/library/artist.{type Artist} +import elekf/library/album.{type Album} import elekf/web/events/start_play import elekf/web/events/show_artist import elekf/web/components/search @@ -26,9 +27,13 @@ pub type DataGetter(a) = fn(Library) -> List(LibraryItem(a)) /// A view that renders a single item from the library. +/// +/// It should return a list of elements, which can be used for expanding items: +/// the first element for the item itself and subsequent elements for the +/// expanded contents. pub type ItemView(a) = fn(Model(a), List(LibraryItem(a)), Int, LibraryItem(a)) -> - element.Element(Msg) + List(element.Element(Msg)) /// A filter that gets the item and the current search string and must return /// `True` if the item should be shown and `False` if not. @@ -64,6 +69,7 @@ pub type Msg { StartPlay(List(LibraryItem(Track)), Int) ShowArtist(LibraryItem(Artist)) Search(search.Msg) + AlbumExpandToggled(LibraryItem(Album)) } pub type Model(a) { @@ -159,6 +165,9 @@ pub fn update(model, msg) { let search_model = search.update(model.search, search_msg) #(Model(..model, search: search_model), effect.none()) } + + // Base case, this should be handled in an implementing view + AlbumExpandToggled(_album) -> #(model, effect.none()) } } @@ -188,7 +197,8 @@ pub fn library_view( [h3([attribute.class("library-item-title")], [text("Shuffle all")])], ), ], - list.index_map(items, fn(i, item) { item_view(model, items, i, item) }), + list.index_map(items, fn(i, item) { item_view(model, items, i, item) }) + |> list.flatten(), ), ) } diff --git a/src/elekf/web/components/library_views/album_item.gleam b/src/elekf/web/components/library_views/album_item.gleam index 7a38b96..299d523 100644 --- a/src/elekf/web/components/library_views/album_item.gleam +++ b/src/elekf/web/components/library_views/album_item.gleam @@ -3,61 +3,118 @@ import gleam/list import gleam/option import gleam/int -import lustre/element/html.{div, h3, img, p} +import lustre/element/html.{div, h3, p} import lustre/element.{text} import lustre/attribute import lustre/event -import ibroadcast/artwork -import elekf/library +import elekf/library.{type Library} import elekf/library/album.{type Album} +import elekf/library/track.{type Track} import elekf/library/album_utils -import elekf/web/components/library_view.{type Model, StartPlay} +import elekf/web/components/library_view.{AlbumExpandToggled} import elekf/web/components/library_item.{type LibraryItem} +import elekf/web/components/library_views/track_item +import elekf/web/components/library_views/thumbnail +import elekf/web/common.{type Settings} + +const base_classes = "library-item album-item" + +const showing_tracks_class = "library-item-expanded" pub fn view( - model: Model(Album), - _items: List(LibraryItem(Album)), - _index: Int, + library: Library, + settings: option.Option(Settings), item: LibraryItem(Album), + show_tracks: Bool, ) { let #(album_id, album) = item - let tracks = album_utils.get_tracks(model.library, album) + let tracks = album_utils.get_tracks(library, album) let artist_name = case album.artist_id { 0 -> "Unknown artist" - id -> library.assert_artist(model.library, id).name + id -> library.assert_artist(library, id).name } let assert Ok(first_track) = list.first(tracks) - div( + let class = case show_tracks { + True -> base_classes <> " " <> showing_tracks_class + False -> base_classes + } + let expanded = case show_tracks { + True -> "true" + False -> "false" + } + + list.append( [ - attribute.id("album-list-" <> int.to_string(album_id)), - attribute.class("library-item album-item"), - event.on_click(StartPlay(tracks, 0)), - attribute.attribute("role", "button"), - ], - [ - case model.settings, { first_track.1 }.artwork_id { - option.Some(s), id if id != 0 -> - img([ - attribute.class("artist-image"), - attribute.alt("artist.name"), - attribute.src(artwork.url( - s.artwork_server, - int.to_string(id), - artwork.S300, - )), - attribute.attribute("loading", "lazy"), - attribute.width(300), - ]) - _, _ -> - div([attribute.class("artist-image-placeholder")], [text(album.name)]) - }, - h3([attribute.class("album-title")], [text(album.name)]), - p([attribute.class("album-artist")], [text(artist_name)]), - p( - [attribute.class("album-tracks")], - [text(int.to_string(list.length(album.tracks)) <> " tracks")], + div( + [ + attribute.id("album-list-" <> int.to_string(album_id)), + attribute.class(class), + ], + [ + div( + [ + attribute.class("album-item-expander"), + event.on_click(AlbumExpandToggled(item)), + event.on_keydown(fn(_) { AlbumExpandToggled(item) }), + attribute.attribute("role", "button"), + attribute.attribute("aria-expanded", expanded), + attribute.attribute("tabindex", "0"), + ], + [ + case settings, { first_track.1 }.artwork_id { + option.Some(s), id if id != 0 -> + thumbnail.view(s, int.to_string(id)) + _, _ -> + div( + [ + attribute.class( + "artist-image-placeholder album-item-image", + ), + ], + [text(album.name)], + ) + }, + h3([attribute.class("album-item-title")], [text(album.name)]), + p([attribute.class("album-item-artist")], [text(artist_name)]), + p( + [attribute.class("album-item-tracks-meta")], + [text(int.to_string(list.length(album.tracks)) <> " tracks")], + ), + ], + ), + ], ), ], + case show_tracks { + True -> [view_tracks(library, tracks)] + False -> [] + }, + ) +} + +pub fn view_tracks(library: Library, tracks: List(LibraryItem(Track))) { + div( + [ + attribute.class( + "library-item-expanded-contents album-item-expanded-tracks", + ), + ], + list.flatten([ + [h3([attribute.class("library-item-title")], [text("Shuffle all")])], + list.index_map( + tracks, + fn(index, track_item) { + track_item.view( + library, + tracks, + track_item, + index, + "album-tracks-list", + ) + }, + ) + |> list.flatten(), + ]), ) } diff --git a/src/elekf/web/components/library_views/albums_view.gleam b/src/elekf/web/components/library_views/albums_view.gleam index a50d370..fcc5f54 100644 --- a/src/elekf/web/components/library_views/albums_view.gleam +++ b/src/elekf/web/components/library_views/albums_view.gleam @@ -3,26 +3,48 @@ import gleam/string import gleam/list import gleam/map +import gleam/set import gleam/option +import gleam/dynamic +import gleam/result +import lustre +import lustre/effect import lustre/attribute +import lustre/element +import elekf/utils/set as set_utils import elekf/library.{type Library} import elekf/library/album.{type Album} import elekf/library/album_utils -import elekf/web/components/library_view +import elekf/web/components/library_view.{AlbumExpandToggled} import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/library_views/album_item import elekf/web/common const component_name = "albums-view" +type Model { + Model(library_view: library_view.Model(Album), expanded_albums: set.Set(Int)) +} + +type Msg { + LibraryViewMsg(library_view.Msg) +} + /// Register the albums view as a custom element. pub fn register() { - library_view.register( + lustre.component( component_name, - data_getter, - album_item.view, - shuffler, - search_filter, + init, + update, + generate_view(search_filter), + library_view.generic_attributes() + |> map.map_values(fn(_key, decoder) { + fn(data: dynamic.Dynamic) { + data + |> decoder() + |> result.map(LibraryViewMsg) + } + }), ) } @@ -35,6 +57,34 @@ pub fn render( library_view.render(component_name, library, settings, extra_attrs) } +fn init() { + let #(lib_m, lib_e) = + library_view.init(library.empty(), data_getter, shuffler) + + #( + Model(library_view: lib_m, expanded_albums: set.new()), + effect.map(lib_e, LibraryViewMsg), + ) +} + +fn update(model: Model, msg) { + case msg { + LibraryViewMsg(AlbumExpandToggled(album)) -> { + #( + Model( + ..model, + expanded_albums: set_utils.toggle(model.expanded_albums, album.0), + ), + effect.none(), + ) + } + LibraryViewMsg(lib_msg) -> { + let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg) + #(Model(..model, library_view: lib_m), effect.map(lib_e, LibraryViewMsg)) + } + } +} + fn data_getter(library: Library) { map.to_list(library.albums) } @@ -49,3 +99,23 @@ fn shuffler(library, items: List(LibraryItem(Album))) { fn search_filter(item: Album, search_text: String) { string.contains(item.name_lower, search_text) } + +fn generate_view(search_filter: library_view.SearchFilter(Album)) { + fn(model: Model) { + library_view.library_view( + model.library_view, + fn(_library_model, _items, _index, item) { view(model, item) }, + search_filter, + ) + |> element.map(LibraryViewMsg) + } +} + +fn view(model: Model, item: LibraryItem(Album)) { + album_item.view( + model.library_view.library, + model.library_view.settings, + item, + set.contains(model.expanded_albums, item.0), + ) +} diff --git a/src/elekf/web/components/library_views/artists_view.gleam b/src/elekf/web/components/library_views/artists_view.gleam index 7cf47d4..6e47750 100644 --- a/src/elekf/web/components/library_views/artists_view.gleam +++ b/src/elekf/web/components/library_views/artists_view.gleam @@ -5,15 +5,15 @@ import gleam/int import gleam/list import gleam/map import gleam/option -import lustre/element/html.{div, h3, img, p} +import lustre/element/html.{div, h3, p} import lustre/element.{text} import lustre/attribute import lustre/event -import ibroadcast/artwork import elekf/library.{type Library} import elekf/library/artist.{type Artist} import elekf/web/components/library_view.{type Model, ShowArtist} import elekf/web/components/library_item.{type LibraryItem} +import elekf/web/components/library_views/thumbnail import elekf/web/common const component_name = "artists-view" @@ -69,37 +69,32 @@ fn item_view( item: LibraryItem(Artist), ) { let #(artist_id, artist) = item - div( - [ - attribute.id("artist-list-" <> int.to_string(artist_id)), - attribute.class("library-item artist-item"), - attribute.type_("button"), - event.on_click(ShowArtist(item)), - attribute.attribute("role", "button"), - ], - [ - case model.settings, artist.artwork_id { - option.Some(s), option.Some(id) -> - img([ - attribute.class("artist-image"), - attribute.alt("artist.name"), - attribute.src(artwork.url(s.artwork_server, id, artwork.S300)), - attribute.attribute("loading", "lazy"), - attribute.width(300), - ]) - _, _ -> - div( - [attribute.class("artist-image-placeholder")], - [text(artist.name)], - ) - }, - h3([attribute.class("artist-title")], [text(artist.name)]), - p( - [attribute.class("artist-tracks")], - [text(int.to_string(list.length(artist.tracks)) <> " tracks")], - ), - ], - ) + [ + div( + [ + attribute.id("artist-list-" <> int.to_string(artist_id)), + attribute.class("library-item artist-item"), + attribute.type_("button"), + event.on_click(ShowArtist(item)), + attribute.attribute("role", "button"), + ], + [ + case model.settings, artist.artwork_id { + option.Some(s), option.Some(id) -> thumbnail.view(s, id) + _, _ -> + div( + [attribute.class("artist-image-placeholder")], + [text(artist.name)], + ) + }, + h3([attribute.class("artist-title")], [text(artist.name)]), + p( + [attribute.class("artist-tracks")], + [text(int.to_string(list.length(artist.tracks)) <> " tracks")], + ), + ], + ), + ] } fn get_tracks(library: Library, artist: Artist) { diff --git a/src/elekf/web/components/library_views/single_artist_view.gleam b/src/elekf/web/components/library_views/single_artist_view.gleam index 89bbb3c..4e1d437 100644 --- a/src/elekf/web/components/library_views/single_artist_view.gleam +++ b/src/elekf/web/components/library_views/single_artist_view.gleam @@ -3,6 +3,7 @@ import gleam/string import gleam/list import gleam/map +import gleam/set import gleam/option import gleam/dynamic import gleam/result @@ -11,11 +12,12 @@ import lustre/element/html.{div, header} import lustre/element.{text} import lustre/attribute import lustre/effect +import elekf/utils/set as set_utils import elekf/library.{type Library} import elekf/library/album.{type Album} import elekf/library/album_utils import elekf/library/artist.{type Artist} -import elekf/web/components/library_view +import elekf/web/components/library_view.{AlbumExpandToggled} import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/library_views/album_item import elekf/web/common @@ -26,6 +28,7 @@ type Model { Model( library_view: library_view.Model(Album), artist: option.Option(LibraryItem(Artist)), + expanded_albums: set.Set(Int), ) } @@ -82,7 +85,7 @@ fn init() { ) #( - Model(artist: option.None, library_view: lib_m), + Model(artist: option.None, expanded_albums: set.new(), library_view: lib_m), effect.map(lib_e, LibraryViewMsg), ) } @@ -92,6 +95,7 @@ fn update(model: Model, msg) { ArtistUpdated(option.Some(artist)) -> #( Model( artist: option.Some(artist), + expanded_albums: set.new(), library_view: library_view.Model( ..model.library_view, data: data_getter(model.library_view.library, artist), @@ -102,10 +106,18 @@ fn update(model: Model, msg) { ArtistUpdated(option.None) -> #( Model( artist: option.None, + expanded_albums: set.new(), library_view: library_view.Model(..model.library_view, data: []), ), effect.none(), ) + LibraryViewMsg(AlbumExpandToggled(album)) -> #( + Model( + ..model, + expanded_albums: set_utils.toggle(model.expanded_albums, album.0), + ), + effect.none(), + ) LibraryViewMsg(lib_msg) -> { let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg) #(Model(..model, library_view: lib_m), effect.map(lib_e, LibraryViewMsg)) @@ -161,7 +173,7 @@ fn view( header([attribute.class("library-header")], [text({ artist.1 }.name)]), library_view.library_view( model.library_view, - album_item.view, + fn(_library_model, _items, _index, item) { album_view(model, item) }, search_filter, ) |> element.map(LibraryViewMsg), @@ -169,6 +181,15 @@ fn view( ) } +fn album_view(model: Model, item: LibraryItem(Album)) { + album_item.view( + model.library_view.library, + model.library_view.settings, + item, + set.contains(model.expanded_albums, item.0), + ) +} + fn artist_decode(data: dynamic.Dynamic) { let artist: option.Option(LibraryItem(Artist)) = dynamic.unsafe_coerce(data) Ok(ArtistUpdated(artist)) diff --git a/src/elekf/web/components/library_views/thumbnail.gleam b/src/elekf/web/components/library_views/thumbnail.gleam new file mode 100644 index 0000000..734e348 --- /dev/null +++ b/src/elekf/web/components/library_views/thumbnail.gleam @@ -0,0 +1,20 @@ +//// A thumbnail element (artist, album...) + +import lustre/element/html.{div, img} +import lustre/attribute +import ibroadcast/artwork +import elekf/web/common.{type Settings} + +pub fn view(s: Settings, id: String) { + div( + [attribute.class("library-item-thumbnail")], + [ + img([ + attribute.alt(""), + attribute.src(artwork.url(s.artwork_server, id, artwork.S300)), + attribute.attribute("loading", "lazy"), + attribute.width(300), + ]), + ], + ) +} diff --git a/src/elekf/web/components/library_views/track_item.gleam b/src/elekf/web/components/library_views/track_item.gleam new file mode 100644 index 0000000..8797c2f --- /dev/null +++ b/src/elekf/web/components/library_views/track_item.gleam @@ -0,0 +1,37 @@ +import gleam/int +import lustre/attribute +import lustre/event +import lustre/element.{text} +import lustre/element/html.{div, h3, p} +import elekf/library.{type Library} +import elekf/library/track.{type Track} +import elekf/web/components/library_item.{type LibraryItem} +import elekf/web/components/library_view.{StartPlay} + +pub fn view( + library: Library, + items: List(LibraryItem(Track)), + item: LibraryItem(Track), + index: Int, + id_prefix: String, +) { + let #(track_id, track) = item + let album = library.assert_album(library, track.album_id) + let artist = library.assert_artist(library, track.artist_id) + [ + div( + [ + attribute.id(id_prefix <> "-" <> int.to_string(track_id)), + attribute.class("library-item track-item"), + attribute.type_("button"), + event.on_click(StartPlay(items, index)), + attribute.attribute("role", "button"), + ], + [ + h3([attribute.class("track-title")], [text(track.title)]), + p([attribute.class("track-artist")], [text(artist.name)]), + p([attribute.class("track-album")], [text(album.name)]), + ], + ), + ] +} diff --git a/src/elekf/web/components/library_views/tracks_view.gleam b/src/elekf/web/components/library_views/tracks_view.gleam index c3812c6..f289f3a 100644 --- a/src/elekf/web/components/library_views/tracks_view.gleam +++ b/src/elekf/web/components/library_views/tracks_view.gleam @@ -1,18 +1,15 @@ //// A library view to all of the tracks in the library. import gleam/string -import gleam/int import gleam/list import gleam/map import gleam/option -import lustre/element/html.{div, h3, p} -import lustre/element.{text} import lustre/attribute -import lustre/event import elekf/library.{type Library} import elekf/library/track.{type Track} -import elekf/web/components/library_view.{type Model, StartPlay} +import elekf/web/components/library_view.{type Model} import elekf/web/components/library_item.{type LibraryItem} +import elekf/web/components/library_views/track_item import elekf/web/common const component_name = "tracks-view" @@ -55,21 +52,5 @@ fn item_view( index: Int, item: LibraryItem(Track), ) { - let #(track_id, track) = item - let album = library.assert_album(model.library, track.album_id) - let artist = library.assert_artist(model.library, track.artist_id) - div( - [ - attribute.id("track-list-" <> int.to_string(track_id)), - attribute.class("library-item track-item"), - attribute.type_("button"), - event.on_click(StartPlay(items, index)), - attribute.attribute("role", "button"), - ], - [ - h3([attribute.class("track-title")], [text(track.title)]), - p([attribute.class("track-artist")], [text(artist.name)]), - p([attribute.class("track-album")], [text(album.name)]), - ], - ) + track_item.view(model.library, items, item, index, "track-list") } diff --git a/style.css b/style.css index ae130b9..a0335e3 100644 --- a/style.css +++ b/style.css @@ -66,12 +66,29 @@ main { } #artists-view .library-list, -#albums-view .library-list { +#albums-view .library-list, +#single-artist-view .library-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-auto-flow: dense; gap: 10px; } +.library-list-shuffle-all { + grid-column: 1 / -1; +} + +.library-item-thumbnail { + aspect-ratio: 1 / 1; +} + +.library-item-expanded { +} + +.library-item-expanded-contents { + grid-column: 1 / -1; +} + .library-item img { }