diff --git a/src/elekf/library/album_utils.gleam b/src/elekf/library/album_utils.gleam new file mode 100644 index 0000000..25e94ad --- /dev/null +++ b/src/elekf/library/album_utils.gleam @@ -0,0 +1,11 @@ +import gleam/list +import elekf/library.{Library} +import elekf/library/album.{Album} + +/// Get the individual tracks in an album. +pub fn get_tracks(library: Library, album: Album) { + list.map( + album.tracks, + fn(track_id) { #(track_id, library.assert_track(library, track_id)) }, + ) +} diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index 5db3141..a038743 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -16,14 +16,18 @@ import ibroadcast/authed_request.{RequestConfig} import elekf/api/base_request_config.{base_request_config} import elekf/utils/http import elekf/library.{Library} +import elekf/library/artist.{Artist} import elekf/library/track.{Track} import elekf/transfer/library as library_transfer import elekf/web/components/player +import elekf/web/components/library_item.{LibraryItem} import elekf/web/components/library_view import elekf/web/components/library_views/tracks_view import elekf/web/components/library_views/artists_view import elekf/web/components/library_views/albums_view +import elekf/web/components/library_views/single_artist_view import elekf/web/events/start_play +import elekf/web/events/show_artist import elekf/web/utils import elekf/web/components/icon.{Alt, icon} @@ -58,6 +62,7 @@ pub type Msg { LibraryResult(Result(library_api.ResponseData, http.ResponseError)) PlayerMsg(player.Msg) StartPlay(PlayQueue, Int) + ShowArtist(LibraryItem(Artist)) ChangeView(library_view.View) } @@ -132,6 +137,10 @@ pub fn update(model: Model, msg) { let #(status, effect) = handle_start_play(model, tracks, position) #(Model(..model, play_status: status), effect) } + ShowArtist(artist) -> #( + Model(..model, library_view: library_view.SingleArtist(artist)), + effect.none(), + ) PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> { if_player( model, @@ -227,7 +236,7 @@ pub fn view(model: Model) { artists_view.render( model.library, model.settings, - [attribute.id("artists-view"), start_play.on(StartPlay)], + [attribute.id("artists-view"), show_artist.on(ShowArtist)], ) library_view.Albums -> albums_view.render( @@ -235,6 +244,13 @@ pub fn view(model: Model) { model.settings, [attribute.id("albums-view"), start_play.on(StartPlay)], ) + library_view.SingleArtist(artist_info) -> + single_artist_view.render( + model.library, + artist_info, + model.settings, + [attribute.id("single-artist-view"), start_play.on(StartPlay)], + ) }, ], ), diff --git a/src/elekf/web/components/library_item.gleam b/src/elekf/web/components/library_item.gleam new file mode 100644 index 0000000..2de4254 --- /dev/null +++ b/src/elekf/web/components/library_item.gleam @@ -0,0 +1,3 @@ +/// An item in the library with its ID. +pub type LibraryItem(a) = + #(Int, a) diff --git a/src/elekf/web/components/library_view.gleam b/src/elekf/web/components/library_view.gleam index faf36e0..333d8e7 100644 --- a/src/elekf/web/components/library_view.gleam +++ b/src/elekf/web/components/library_view.gleam @@ -14,14 +14,13 @@ import lustre/attribute import lustre/event import elekf/library.{Library} import elekf/library/track.{Track} +import elekf/library/artist.{Artist} import elekf/web/events/start_play +import elekf/web/events/show_artist import elekf/web/components/search +import elekf/web/components/library_item.{LibraryItem} import elekf/web/common -/// An item in the library with its ID. -pub type LibraryItem(a) = - #(Int, a) - /// Function to get the data of the view from the library. pub type DataGetter(a) = fn(Library) -> List(LibraryItem(a)) @@ -51,7 +50,7 @@ pub type View { /// All artists. Artists /// Albums of a single artist. - /// SingleArtist(Int, Artist) + SingleArtist(LibraryItem(Artist)) /// Tracks of a single artist. /// SingleArtistTracks(Int, Artist) /// All tracks. @@ -63,6 +62,7 @@ pub type Msg { SettingsUpdated(option.Option(common.Settings)) ShuffleAll StartPlay(List(LibraryItem(Track)), Int) + ShowArtist(LibraryItem(Artist)) Search(search.Msg) } @@ -98,10 +98,16 @@ pub fn register( fn() { init(library.empty(), data_getter, shuffler) }, update, generate_view(item_view, search_filter), - map.from_list([#("library", library_decode), #("settings", settings_decode)]), + generic_attributes(), ) } +/// Get the generic properties common to all library views, that can be input +/// into the Lustre component attribute change +pub fn generic_attributes() { + map.from_list([#("library", library_decode), #("settings", settings_decode)]) +} + /// Render the component using a custom element. pub fn render( name: String, @@ -111,7 +117,7 @@ pub fn render( ) { element.element( name, - list.concat([ + list.flatten([ [ attribute.property("library", library), attribute.property("settings", settings), @@ -122,7 +128,7 @@ pub fn render( ) } -fn init(library, data_getter, shuffler) { +pub fn init(library, data_getter, shuffler) { #( Model( library, @@ -136,7 +142,7 @@ fn init(library, data_getter, shuffler) { ) } -fn update(model, msg) { +pub fn update(model, msg) { case msg { LibraryUpdated(library) -> #( Model(..model, library: library, data: model.data_getter(library)), @@ -148,6 +154,7 @@ fn update(model, msg) { ) ShuffleAll -> #(model, shuffle_all(model)) StartPlay(tracks, position) -> #(model, start_play.emit(tracks, position)) + ShowArtist(artist) -> #(model, show_artist.emit(artist)) Search(search_msg) -> { let search_model = search.update(model.search, search_msg) #(Model(..model, search: search_model), effect.none()) @@ -155,33 +162,39 @@ fn update(model, msg) { } } -fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) { - fn(model: Model(a)) { - let items = case model.search.search_text { - "" -> model.data - txt -> - model.data - |> list.filter(fn(item) { search_filter(item.1, txt) }) - } - - div( - [attribute.class("library-list")], - list.append( - [ - search.view(model.search) - |> element.map(Search), - div( - [ - attribute.class("library-item library-list-shuffle-all"), - event.on_click(ShuffleAll), - ], - [h3([attribute.class("library-item-title")], [text("Shuffle all")])], - ), - ], - list.index_map(items, fn(i, item) { item_view(model, items, i, item) }), - ), - ) +pub fn library_view( + model: Model(a), + item_view: ItemView(a), + search_filter: SearchFilter(a), +) { + let items = case model.search.search_text { + "" -> model.data + txt -> + model.data + |> list.filter(fn(item) { search_filter(item.1, txt) }) } + + div( + [attribute.class("library-list")], + list.append( + [ + search.view(model.search) + |> element.map(Search), + div( + [ + attribute.class("library-item library-list-shuffle-all"), + event.on_click(ShuffleAll), + ], + [h3([attribute.class("library-item-title")], [text("Shuffle all")])], + ), + ], + list.index_map(items, fn(i, item) { item_view(model, items, i, item) }), + ), + ) +} + +fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) { + library_view(_, item_view, search_filter) } fn shuffle_all(model: Model(a)) { diff --git a/src/elekf/web/components/library_views/album_item.gleam b/src/elekf/web/components/library_views/album_item.gleam new file mode 100644 index 0000000..ee43579 --- /dev/null +++ b/src/elekf/web/components/library_views/album_item.gleam @@ -0,0 +1,64 @@ +//// Library item view for a single album. + +import gleam/list +import gleam/option +import gleam/int +import lustre/element/html.{div, h3, img, p} +import lustre/element.{text} +import lustre/attribute +import lustre/event +import ibroadcast/artwork +import elekf/library +import elekf/library/album.{Album} +import elekf/library/album_utils +import elekf/web/components/library_view.{Model, StartPlay} +import elekf/web/components/library_item.{LibraryItem} + +pub fn view( + model: Model(Album), + _items: List(LibraryItem(Album)), + _index: Int, + item: LibraryItem(Album), +) { + let #(album_id, album) = item + let tracks = album_utils.get_tracks(model.library, album) + let artist_name = case album.artist_id { + 0 -> "Unknown artist" + id -> library.assert_artist(model.library, id).name + } + let assert Ok(first_track) = list.first(tracks) + + div( + [ + attribute.id("album-list-" <> int.to_string(album_id)), + attribute.class("library-item album-item"), + attribute.type_("button"), + 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")], + ), + ], + ) +} diff --git a/src/elekf/web/components/library_views/albums_view.gleam b/src/elekf/web/components/library_views/albums_view.gleam index 13e4775..677662d 100644 --- a/src/elekf/web/components/library_views/albums_view.gleam +++ b/src/elekf/web/components/library_views/albums_view.gleam @@ -1,18 +1,16 @@ //// A library view to all of the albums in the library. import gleam/string -import gleam/int import gleam/list import gleam/map import gleam/option -import lustre/element/html.{div, h3, img, p} -import lustre/element.{text} import lustre/attribute -import lustre/event -import ibroadcast/artwork import elekf/library.{Library} import elekf/library/album.{Album} -import elekf/web/components/library_view.{LibraryItem, Model, StartPlay} +import elekf/library/album_utils +import elekf/web/components/library_view +import elekf/web/components/library_item.{LibraryItem} +import elekf/web/components/library_views/album_item import elekf/web/common const component_name = "albums-view" @@ -22,7 +20,7 @@ pub fn register() { library_view.register( component_name, data_getter, - item_view, + album_item.view, shuffler, search_filter, ) @@ -44,66 +42,10 @@ fn data_getter(library: Library) { fn shuffler(library, items: List(LibraryItem(Album))) { items |> list.shuffle() - |> list.map(fn(album) { get_tracks(library, album.1) }) + |> list.map(fn(album) { album_utils.get_tracks(library, album.1) }) |> list.flatten() } fn search_filter(item: Album, search_text: String) { string.contains(item.name_lower, search_text) } - -fn item_view( - model: Model(Album), - _items: List(LibraryItem(Album)), - _index: Int, - item: LibraryItem(Album), -) { - let #(album_id, album) = item - let tracks = get_tracks(model.library, album) - let artist_name = case album.artist_id { - 0 -> "Unknown artist" - id -> library.assert_artist(model.library, id).name - } - let assert Ok(first_track) = list.first(tracks) - - div( - [ - attribute.id("album-list-" <> int.to_string(album_id)), - attribute.class("library-item album-item"), - attribute.type_("button"), - 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")], - ), - ], - ) -} - -fn get_tracks(library: Library, album: Album) { - list.map( - album.tracks, - fn(track_id) { #(track_id, library.assert_track(library, track_id)) }, - ) -} diff --git a/src/elekf/web/components/library_views/artists_view.gleam b/src/elekf/web/components/library_views/artists_view.gleam index 9ee2118..eff6284 100644 --- a/src/elekf/web/components/library_views/artists_view.gleam +++ b/src/elekf/web/components/library_views/artists_view.gleam @@ -12,7 +12,8 @@ import lustre/event import ibroadcast/artwork import elekf/library.{Library} import elekf/library/artist.{Artist} -import elekf/web/components/library_view.{LibraryItem, Model, StartPlay} +import elekf/web/components/library_view.{Model, ShowArtist} +import elekf/web/components/library_item.{LibraryItem} import elekf/web/common const component_name = "artists-view" @@ -68,13 +69,12 @@ fn item_view( item: LibraryItem(Artist), ) { let #(artist_id, artist) = item - let tracks = get_tracks(model.library, artist) div( [ attribute.id("artist-list-" <> int.to_string(artist_id)), attribute.class("library-item artist-item"), attribute.type_("button"), - event.on_click(StartPlay(tracks, 0)), + event.on_click(ShowArtist(item)), attribute.attribute("role", "button"), ], [ diff --git a/src/elekf/web/components/library_views/single_artist_view.gleam b/src/elekf/web/components/library_views/single_artist_view.gleam new file mode 100644 index 0000000..8d6cafc --- /dev/null +++ b/src/elekf/web/components/library_views/single_artist_view.gleam @@ -0,0 +1,175 @@ +//// A library view to a single artist's albums. + +import gleam/string +import gleam/list +import gleam/map +import gleam/option +import gleam/dynamic +import gleam/result +import lustre +import lustre/element/html.{div, header} +import lustre/element.{text} +import lustre/attribute +import lustre/effect +import elekf/library.{Library} +import elekf/library/album.{Album} +import elekf/library/album_utils +import elekf/library/artist.{Artist} +import elekf/web/components/library_view +import elekf/web/components/library_item.{LibraryItem} +import elekf/web/components/library_views/album_item +import elekf/web/common + +const component_name = "single-artist-view" + +type Model { + Model( + library_view: library_view.Model(Album), + artist: option.Option(LibraryItem(Artist)), + ) +} + +type Msg { + LibraryViewMsg(library_view.Msg) + ArtistUpdated(option.Option(LibraryItem(Artist))) +} + +/// Register the single artist view as a custom element. +pub fn register() { + lustre.component( + component_name, + init, + update, + generate_view(search_filter), + map.merge( + library_view.generic_attributes() + |> map.map_values(fn(_key, decoder) { + fn(data: dynamic.Dynamic) { + data + |> decoder() + |> result.map(LibraryViewMsg) + } + }), + map.from_list([#("artist", artist_decode)]), + ), + ) +} + +/// Render the single artist view. +pub fn render( + library: Library, + artist: LibraryItem(Artist), + settings: option.Option(common.Settings), + extra_attrs: List(attribute.Attribute(msg)), +) { + library_view.render( + component_name, + library, + settings, + list.flatten([ + [attribute.property("artist", option.Some(artist))], + extra_attrs, + ]), + ) +} + +fn init() { + let #(lib_m, lib_e) = + library_view.init( + library.empty(), + fn(_) -> List(LibraryItem(Album)) { [] }, + shuffler, + ) + + #( + Model(artist: option.None, library_view: lib_m), + effect.map(lib_e, LibraryViewMsg), + ) +} + +fn update(model: Model, msg) { + case msg { + ArtistUpdated(option.Some(artist)) -> #( + Model( + artist: option.Some(artist), + library_view: library_view.Model( + ..model.library_view, + data: data_getter(model.library_view.library, artist), + ), + ), + effect.none(), + ) + ArtistUpdated(option.None) -> #( + Model( + artist: option.None, + library_view: library_view.Model(..model.library_view, data: []), + ), + 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, artist: LibraryItem(Artist)) { + library.albums + |> map.fold( + [], + fn(acc, key, val) { + case val.artist_id == artist.0 { + True -> [#(key, val), ..acc] + False -> acc + } + }, + ) +} + +fn shuffler(library, items: List(LibraryItem(Album))) { + items + |> list.shuffle() + |> list.map(fn(album) { album_utils.get_tracks(library, album.1) }) + |> list.flatten() +} + +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) { + case model.artist { + option.None -> + div( + [attribute.class("library-view-not-specified")], + [text("Artist not specified.")], + ) + option.Some(artist) -> view(model, artist, search_filter) + } + } +} + +fn view( + model: Model, + artist: LibraryItem(Artist), + search_filter: library_view.SearchFilter(Album), +) { + div( + [], + [ + header([attribute.class("library-header")], [text({ artist.1 }.name)]), + library_view.library_view( + model.library_view, + album_item.view, + search_filter, + ) + |> element.map(LibraryViewMsg), + ], + ) +} + +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/tracks_view.gleam b/src/elekf/web/components/library_views/tracks_view.gleam index f67c18d..6ec740a 100644 --- a/src/elekf/web/components/library_views/tracks_view.gleam +++ b/src/elekf/web/components/library_views/tracks_view.gleam @@ -11,7 +11,8 @@ import lustre/attribute import lustre/event import elekf/library.{Library} import elekf/library/track.{Track} -import elekf/web/components/library_view.{LibraryItem, Model, StartPlay} +import elekf/web/components/library_view.{Model, StartPlay} +import elekf/web/components/library_item.{LibraryItem} import elekf/web/common const component_name = "tracks-view" diff --git a/src/elekf/web/events/show_artist.gleam b/src/elekf/web/events/show_artist.gleam new file mode 100644 index 0000000..304c669 --- /dev/null +++ b/src/elekf/web/events/show_artist.gleam @@ -0,0 +1,38 @@ +import gleam/result +import gleam/dynamic +import lustre/event +import elekf/library/artist.{Artist} +import elekf/web/components/library_item.{LibraryItem} +import elekf/utils/custom_event.{CustomEvent} + +const event_name = "show-artist" + +pub type EventData { + EventData(artist: LibraryItem(Artist)) +} + +pub fn emit(artist) { + event.emit(event_name, EventData(artist)) +} + +pub fn on(msg: fn(LibraryItem(Artist)) -> b) { + event.on( + event_name, + fn(data) { + data + |> decoder + |> result.map(fn(e) { msg(e.artist) }) + }, + ) +} + +fn decoder(data: dynamic.Dynamic) { + let e: CustomEvent = dynamic.unsafe_coerce(data) + let detail = custom_event.get_detail(e) + let event_data: EventData = + detail + |> dynamic.from() + |> dynamic.unsafe_coerce() + + Ok(event_data) +} diff --git a/src/elekf/web/main.gleam b/src/elekf/web/main.gleam index b61b1cf..1cc788d 100644 --- a/src/elekf/web/main.gleam +++ b/src/elekf/web/main.gleam @@ -15,6 +15,7 @@ import elekf/web/utils import elekf/web/components/library_views/tracks_view import elekf/web/components/library_views/artists_view import elekf/web/components/library_views/albums_view +import elekf/web/components/library_views/single_artist_view import elekf/api/auth/storage as auth_storage import elekf/api/auth/models as auth_models import elekf/utils/option as option_utils @@ -39,6 +40,7 @@ pub fn main() { let assert Ok(_) = tracks_view.register() let assert Ok(_) = artists_view.register() let assert Ok(_) = albums_view.register() + let assert Ok(_) = single_artist_view.register() let app = lustre.application(init, update, view) let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)