Implement single artist view

This commit is contained in:
Mikko Ahlroth 2023-11-01 19:52:02 +02:00
parent eb8b6df527
commit 899d63bb36
11 changed files with 369 additions and 104 deletions

View file

@ -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)) },
)
}

View file

@ -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)],
)
},
],
),

View file

@ -0,0 +1,3 @@
/// An item in the library with its ID.
pub type LibraryItem(a) =
#(Int, a)

View file

@ -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,8 +162,11 @@ fn update(model, msg) {
}
}
fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) {
fn(model: Model(a)) {
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 ->
@ -182,6 +192,9 @@ fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) {
),
)
}
fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) {
library_view(_, item_view, search_filter)
}
fn shuffle_all(model: Model(a)) {

View file

@ -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")],
),
],
)
}

View file

@ -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)) },
)
}

View file

@ -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"),
],
[

View file

@ -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))
}

View file

@ -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"

View file

@ -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)
}

View file

@ -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)