Initial work for play queue

This commit is contained in:
Mikko Ahlroth 2024-03-02 14:10:36 +02:00
parent 5b8f4fdd2d
commit 33f8cd92f2
8 changed files with 173 additions and 387 deletions

View file

@ -8,14 +8,15 @@ pub type Sorter(a) =
/// Compare two values by using multiple sorters. The comparison will stop after
/// the first sorter that returns something other than `order.Eq`.
pub fn compare_by_multiple(sorters: List(Sorter(a)), a: a, b: a) {
list.fold_until(
sorters,
order.Eq,
fn(prev, sorter) {
list.fold_until(sorters, order.Eq, fn(prev, sorter) {
case prev {
order.Eq -> list.Continue(sorter(a, b))
other -> list.Stop(other)
}
},
)
})
}
/// A sorter that does not sort, keeping the order of the elements.
pub fn noop(_a, _b) {
order.Eq
}

View file

@ -27,17 +27,17 @@ import elekf/web/common
import elekf/web/storage/history/storage as history_store
/// Function to get the data of the view from the library.
pub type DataGetter(a) =
fn(Library) -> List(LibraryItem(a))
pub type DataGetter(a, filter) =
fn(Library, filter) -> 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)) ->
List(element.Element(Msg))
pub type ItemView(a, filter) =
fn(Model(a, filter), List(LibraryItem(a)), Int, LibraryItem(a)) ->
List(element.Element(Msg(filter)))
/// 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.
@ -66,24 +66,25 @@ pub type View {
Tracks
}
pub type Msg {
pub type Msg(filter) {
LibraryUpdated(Library)
SettingsUpdated(option.Option(common.Settings))
ShuffleAll
StartPlay(List(LibraryItem(Track)), Int)
Search(search.Msg)
FilterUpdated
FilterUpdated(filter)
ListScrolled(Float)
ScrollRequested(Float)
}
pub type Model(a) {
pub type Model(a, filter) {
Model(
id: String,
filter: filter,
library: Library,
library_loading: Bool,
data: List(LibraryItem(a)),
data_getter: DataGetter(a),
data_getter: DataGetter(a, filter),
shuffler: Shuffler(a),
sorter: Sorter(a),
search: search.Model,
@ -104,18 +105,30 @@ pub type Model(a) {
/// and shuffle them for play.
pub fn register(
name: String,
data_getter: DataGetter(a),
item_view: ItemView(a),
default_filter: filter,
data_getter: DataGetter(a, filter),
item_view: ItemView(a, filter),
shuffler: Shuffler(a),
sorter: Sorter(a),
search_filter: SearchFilter(a),
extra_attrs: dict.Dict(String, dynamic.Decoder(Msg(filter))),
) {
lustre.component(
name,
fn() { init(name, library.empty(), True, data_getter, shuffler, sorter) },
fn() {
init(
name,
default_filter,
library.empty(),
True,
data_getter,
shuffler,
sorter,
)
},
update,
generate_view(item_view, search_filter),
generic_attributes(),
dict.merge(generic_attributes(), extra_attrs),
)
}
@ -146,7 +159,7 @@ pub fn render(
@external(javascript, "../../../library_view_ffi.mjs", "requestScroll")
pub fn request_scroll(pos: Float) -> Nil
pub fn init(id, library, library_loading, data_getter, shuffler, sorter) {
pub fn init(id, filter, library, library_loading, data_getter, shuffler, sorter) {
let scrollend_effect =
effect.from(fn(dispatch) {
lustre_utils.after_next_render(fn() {
@ -169,9 +182,10 @@ pub fn init(id, library, library_loading, data_getter, shuffler, sorter) {
#(
Model(
id,
filter,
library,
library_loading,
data_getter(library),
data_getter(library, filter),
data_getter,
shuffler,
sorter,
@ -202,7 +216,10 @@ pub fn update(model, msg) {
#(Model(..model, search: search_model), effect.none())
}
FilterUpdated -> #(update_data(model, model.library), effect.none())
FilterUpdated(filter) -> {
let new_model = Model(..model, filter: filter)
#(update_data(new_model, new_model.library), effect.none())
}
ListScrolled(pos) -> {
let view_history = get_view_history(model.history_api)
@ -224,8 +241,8 @@ pub fn update(model, msg) {
}
pub fn library_view(
model: Model(a),
item_view: ItemView(a),
model: Model(a, filter),
item_view: ItemView(a, filter),
search_filter: SearchFilter(a),
) {
let items = case model.search.search_text {
@ -251,11 +268,11 @@ pub fn library_view(
)
}
fn generate_view(item_view: ItemView(a), search_filter: SearchFilter(a)) {
fn generate_view(item_view: ItemView(a, filter), search_filter: SearchFilter(a)) {
library_view(_, item_view, search_filter)
}
fn shuffle_all(model: Model(a)) {
fn shuffle_all(model: Model(a, filter)) {
let tracks = model.shuffler(model.library, model.data)
start_play.emit(tracks, 0)
}
@ -270,11 +287,11 @@ fn settings_decode(data: dynamic.Dynamic) {
Ok(SettingsUpdated(settings))
}
fn update_data(model: Model(a), library: Library) {
fn update_data(model: Model(a, filter), library: Library) {
Model(
..model,
data: library
|> model.data_getter()
|> model.data_getter(model.filter)
|> list.sort(fn(a, b) { model.sorter(a.1, b.1) }),
)
}

View file

@ -4,45 +4,28 @@ import gleam/string
import gleam/list
import gleam/dict
import gleam/option
import gleam/dynamic
import gleam/result
import lustre
import lustre/effect
import lustre/attribute
import lustre/element
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.{type Model}
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))
}
type Msg {
LibraryViewMsg(library_view.Msg)
}
/// Register the albums view as a custom element.
pub fn register() {
lustre.component(
library_view.register(
component_name,
init,
update,
generate_view(search_filter),
library_view.generic_attributes()
|> dict.map_values(fn(_key, decoder) {
fn(data: dynamic.Dynamic) {
data
|> decoder()
|> result.map(LibraryViewMsg)
}
}),
Nil,
data_getter,
item_view,
shuffler,
album_utils.sort_by_name,
search_filter,
dict.new(),
)
}
@ -55,30 +38,7 @@ pub fn render(
library_view.render(component_name, library, settings, extra_attrs)
}
fn init() {
let #(lib_m, lib_e) =
library_view.init(
component_name,
library.empty(),
True,
data_getter,
shuffler,
album_utils.sort_by_name,
)
#(Model(library_view: lib_m), effect.map(lib_e, LibraryViewMsg))
}
fn update(model: Model, msg) {
case msg {
LibraryViewMsg(lib_msg) -> {
let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg)
#(Model(library_view: lib_m), effect.map(lib_e, LibraryViewMsg))
}
}
}
fn data_getter(library: Library) {
fn data_getter(library: Library, _filter: Nil) {
dict.to_list(library.albums)
}
@ -93,17 +53,11 @@ 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)
fn item_view(
model: Model(Album, Nil),
_items: List(LibraryItem(Album)),
_index: Int,
item: LibraryItem(Album),
) {
album_item.view(model.library, model.settings, item)
}

View file

@ -24,11 +24,13 @@ const component_name = "artists-view"
pub fn register() {
library_view.register(
component_name,
Nil,
data_getter,
item_view,
shuffler,
artist_utils.sort_by_name,
search_filter,
dict.new(),
)
}
@ -41,7 +43,7 @@ pub fn render(
library_view.render(component_name, library, settings, extra_attrs)
}
fn data_getter(library: Library) {
fn data_getter(library: Library, _filter: Nil) {
library.artists
|> dict.fold([], fn(acc, key, val) {
case val.tracks {
@ -63,7 +65,7 @@ fn search_filter(item: Artist, search_text: String) {
}
fn item_view(
model: Model(Artist),
model: Model(Artist, Nil),
_items: List(LibraryItem(Artist)),
_index: Int,
item: LibraryItem(Artist),

View file

@ -0,0 +1,60 @@
//// A library view to the current play queue.
import gleam/string
import gleam/list
import gleam/dict
import gleam/option
import lustre/attribute
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/utils/order
import elekf/web/components/library_view.{type Model} as library_view
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/track_item
import elekf/web/common
const component_name = "play-queue-view"
/// Register the play queue view as a custom element.
pub fn register() {
library_view.register(
component_name,
Nil,
data_getter,
item_view,
shuffler,
order.noop,
search_filter,
dict.new(),
)
}
/// Render the play queue view.
pub fn render(
library: Library,
settings: option.Option(common.Settings),
extra_attrs: List(attribute.Attribute(msg)),
) {
library_view.render(component_name, library, settings, extra_attrs)
}
fn data_getter(library: Library, _filter: Nil) {
dict.to_list(library.tracks)
}
fn shuffler(_library, items) {
list.shuffle(items)
}
fn search_filter(item: Track, search_text: String) {
string.contains(item.title_lower, search_text)
}
fn item_view(
model: Model(Track, Nil),
items: List(LibraryItem(Track)),
index: Int,
item: LibraryItem(Track),
) {
track_item.view(model.library, items, index, item, "track-list")
}

View file

@ -5,15 +5,8 @@ import gleam/list
import gleam/dict
import gleam/option
import gleam/dynamic
import gleam/result
import gleam/int
import lustre
import lustre/element/html.{div, header}
import lustre/element.{text}
import lustre/attribute
import lustre/effect
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/track.{type Track}
import elekf/library/track_utils
import elekf/web/components/library_view
@ -23,37 +16,17 @@ import elekf/web/common
const component_name = "single-album-view"
type Model {
Model(
library_view: library_view.Model(Track),
album_id: Int,
album: option.Option(Album),
)
}
type Msg {
LibraryViewMsg(library_view.Msg)
AlbumUpdated(Int)
}
/// Register the single album view as a custom element.
pub fn register() {
lustre.component(
library_view.register(
component_name,
init,
update,
generate_view(search_filter),
dict.merge(
library_view.generic_attributes()
|> dict.map_values(fn(_key, decoder) {
fn(data: dynamic.Dynamic) {
data
|> decoder()
|> result.map(LibraryViewMsg)
}
}),
library.invalid_id,
data_getter,
item_view,
shuffler,
track_utils.sort_by_track_number,
search_filter,
dict.from_list([#("album-id", id_decode)]),
),
)
}
@ -70,67 +43,6 @@ pub fn render(
])
}
fn init() {
let #(lib_m, lib_e) =
library_view.init(
component_name,
library.empty(),
True,
fn(_) -> List(LibraryItem(Track)) { [] },
shuffler,
track_utils.sort_by_track_number,
)
#(
Model(album_id: library.invalid_id, album: option.None, library_view: lib_m),
effect.map(lib_e, LibraryViewMsg),
)
}
fn update(model: Model, msg) {
case msg {
AlbumUpdated(id) -> {
let new_getter = data_getter(_, id)
let album =
load_album(model.library_view.library, id)
|> option.from_result()
#(
Model(
album_id: id,
album: album,
library_view: library_view.Model(
..model.library_view,
data_getter: new_getter,
),
),
effect.map(
effect.from(fn(dispatch) { dispatch(library_view.FilterUpdated) }),
LibraryViewMsg,
),
)
}
LibraryViewMsg(lib_msg) -> {
let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg)
// Update album when library is updated
let album = case lib_msg {
library_view.LibraryUpdated(new_lib) ->
load_album(new_lib, model.album_id)
|> option.from_result()
_ -> model.album
}
#(
Model(..model, album: album, library_view: lib_m),
effect.map(lib_e, LibraryViewMsg),
)
}
}
}
fn data_getter(library: Library, album_id: Int) {
library.tracks
|> dict.fold([], fn(acc, key, val) {
@ -150,52 +62,16 @@ fn search_filter(item: Track, search_text: String) {
string.contains(item.title_lower, search_text)
}
fn generate_view(search_filter: library_view.SearchFilter(Track)) {
fn(model: Model) {
case model.library_view.library_loading, model.album {
True, _ ->
div([attribute.class("library-view-loading")], [
text("Loading library…"),
])
False, option.None ->
div([attribute.class("library-view-not-specified")], [
text("Album not found with ID: " <> int.to_string(model.album_id)),
])
False, option.Some(album) ->
view(model, #(model.album_id, album), search_filter)
}
}
}
fn view(
model: Model,
album: LibraryItem(Album),
search_filter: library_view.SearchFilter(Track),
fn item_view(
model: library_view.Model(Track, Int),
items: List(LibraryItem(Track)),
index: Int,
item: LibraryItem(Track),
) {
div([], [
header([attribute.class("library-header")], [text({ album.1 }.name)]),
library_view.library_view(
model.library_view,
fn(library, items, index, item) {
track_item.view(
library.library,
items,
index,
item,
"album-tracks-list",
)
},
search_filter,
)
|> element.map(LibraryViewMsg),
])
track_item.view(model.library, items, index, item, "album-tracks-list")
}
fn id_decode(data: dynamic.Dynamic) {
let album_id: Int = dynamic.unsafe_coerce(data)
Ok(AlbumUpdated(album_id))
}
fn load_album(library: Library, id: Int) {
library.get_album(library, id)
Ok(library_view.FilterUpdated(album_id))
}

View file

@ -5,55 +5,28 @@ import gleam/list
import gleam/dict
import gleam/option
import gleam/dynamic
import gleam/result
import gleam/int
import lustre
import lustre/element/html.{div, header}
import lustre/element.{text}
import lustre/attribute
import lustre/effect
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/album_utils
import elekf/library/artist.{type Artist}
import elekf/library/track.{type Track}
import elekf/library/track_utils
import elekf/web/components/library_view
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/album_item
import elekf/web/components/library_views/track_item
import elekf/web/common
const component_name = "single-artist-view"
type Model {
Model(
library_view: library_view.Model(Album),
artist_id: Int,
artist: option.Option(Artist),
)
}
type Msg {
LibraryViewMsg(library_view.Msg)
ArtistUpdated(Int)
}
/// Register the single artist view as a custom element.
pub fn register() {
lustre.component(
library_view.register(
component_name,
init,
update,
generate_view(search_filter),
dict.merge(
library_view.generic_attributes()
|> dict.map_values(fn(_key, decoder) {
fn(data: dynamic.Dynamic) {
data
|> decoder()
|> result.map(LibraryViewMsg)
}
}),
library.invalid_id,
data_getter,
item_view,
shuffler,
track_utils.sort_by_name,
search_filter,
dict.from_list([#("artist-id", id_decode)]),
),
)
}
@ -70,134 +43,35 @@ pub fn render(
])
}
fn init() {
let #(lib_m, lib_e) =
library_view.init(
component_name,
library.empty(),
True,
fn(_) -> List(LibraryItem(Album)) { [] },
shuffler,
album_utils.sort_by_year,
)
#(
Model(
artist_id: library.invalid_id,
artist: option.None,
library_view: lib_m,
),
effect.map(lib_e, LibraryViewMsg),
)
}
fn update(model: Model, msg) {
case msg {
ArtistUpdated(id) -> {
let new_getter = data_getter(_, id)
let artist =
load_artist(model.library_view.library, id)
|> option.from_result()
#(
Model(
artist_id: id,
artist: artist,
library_view: library_view.Model(
..model.library_view,
data_getter: new_getter,
),
),
effect.map(
effect.from(fn(dispatch) { dispatch(library_view.FilterUpdated) }),
LibraryViewMsg,
),
)
}
LibraryViewMsg(lib_msg) -> {
let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg)
// Update artist when library is updated
let artist = case lib_msg {
library_view.LibraryUpdated(new_lib) ->
load_artist(new_lib, model.artist_id)
|> option.from_result()
_ -> model.artist
}
#(
Model(..model, artist: artist, library_view: lib_m),
effect.map(lib_e, LibraryViewMsg),
)
}
}
}
fn data_getter(library: Library, artist_id: Int) {
library.albums
library.tracks
|> dict.fold([], fn(acc, key, val) {
case val.artist_id == artist_id {
case val.artist_id == artist_id && val.album_id == 0 {
True -> [#(key, val), ..acc]
False -> acc
}
})
}
fn shuffler(library, items: List(LibraryItem(Album))) {
fn shuffler(_library, items: List(LibraryItem(Track))) {
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 search_filter(item: Track, search_text: String) {
string.contains(item.title_lower, search_text)
}
fn generate_view(search_filter: library_view.SearchFilter(Album)) {
fn(model: Model) {
case model.library_view.library_loading, model.artist {
True, _ ->
div([attribute.class("library-view-loading")], [
text("Loading library…"),
])
False, option.None ->
div([attribute.class("library-view-not-specified")], [
text("Artist not found with ID: " <> int.to_string(model.artist_id)),
])
False, option.Some(artist) ->
view(model, #(model.artist_id, artist), search_filter)
}
}
}
fn view(
model: Model,
artist: LibraryItem(Artist),
search_filter: library_view.SearchFilter(Album),
fn item_view(
model: library_view.Model(Track, Int),
items: List(LibraryItem(Track)),
index: Int,
item: LibraryItem(Track),
) {
div([], [
header([attribute.class("library-header")], [text({ artist.1 }.name)]),
library_view.library_view(
model.library_view,
fn(_library_model, _items, _index, item) { album_view(model, item) },
search_filter,
)
|> element.map(LibraryViewMsg),
])
}
fn album_view(model: Model, item: LibraryItem(Album)) {
album_item.view(model.library_view.library, model.library_view.settings, item)
track_item.view(model.library, items, index, item, "artist-tracks-list")
}
fn id_decode(data: dynamic.Dynamic) {
let artist_id: Int = dynamic.unsafe_coerce(data)
Ok(ArtistUpdated(artist_id))
}
fn load_artist(library: Library, id: Int) {
library.get_artist(library, id)
Ok(library_view.FilterUpdated(artist_id))
}

View file

@ -19,11 +19,13 @@ const component_name = "tracks-view"
pub fn register() {
library_view.register(
component_name,
Nil,
data_getter,
item_view,
shuffler,
track_utils.sort_by_name,
search_filter,
dict.new(),
)
}
@ -36,7 +38,7 @@ pub fn render(
library_view.render(component_name, library, settings, extra_attrs)
}
fn data_getter(library: Library) {
fn data_getter(library: Library, _filter: Nil) {
dict.to_list(library.tracks)
}
@ -49,7 +51,7 @@ fn search_filter(item: Track, search_text: String) {
}
fn item_view(
model: Model(Track),
model: Model(Track, Nil),
items: List(LibraryItem(Track)),
index: Int,
item: LibraryItem(Track),