diff --git a/.tool-versions b/.tool-versions index 1865fec..aa5a120 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -gleam 0.34.1 +gleam 1.0.0 nodejs 20.10.0 diff --git a/manifest.toml b/manifest.toml index fb39a4d..2d7d0de 100644 --- a/manifest.toml +++ b/manifest.toml @@ -6,19 +6,19 @@ packages = [ { name = "birl", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "23BFE5AB0D7D9E4ECC5BB89B7ABDDF8E976D98C65D2E173D116E6AAFBF24E633" }, { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, - { name = "gleam_fetch", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_http", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "24F83C6EF5BC274EF4D712B374D7CDB795136883DA64E2BA6435AF8E6C57E6E2" }, + { name = "gleam_fetch", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "gleam_javascript"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "24F83C6EF5BC274EF4D712B374D7CDB795136883DA64E2BA6435AF8E6C57E6E2" }, { name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" }, { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" }, - { name = "lustre", version = "3.1.4", build_tools = ["gleam"], requirements = ["argv", "gleam_stdlib", "glint", "gleam_community_ansi"], otp_app = "lustre", source = "hex", outer_checksum = "E651E39189F55473837FB7386C06BAED7276B37B5058302CAC880F89C25CB4E9" }, - { name = "plinth", version = "0.1.9", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "89BE43DC719539A676A9515C619315A2A7188A6FF5D7499F8261241BC0B2F1A9" }, + { name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "snag", "gleam_community_colour", "gleam_stdlib"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" }, + { name = "lustre", version = "3.1.4", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "argv", "glint"], otp_app = "lustre", source = "hex", outer_checksum = "E651E39189F55473837FB7386C06BAED7276B37B5058302CAC880F89C25CB4E9" }, + { name = "plinth", version = "0.1.9", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "89BE43DC719539A676A9515C619315A2A7188A6FF5D7499F8261241BC0B2F1A9" }, { name = "ranger", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "28E615AE7590ED922AF1510DDF606A2ECBBC2A9609AF36D412EDC925F06DFD20" }, { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, - { name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "plinth", "gleam_json"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" }, + { name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" }, ] [requirements] diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index 2254da8..d2c2ac2 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -296,6 +296,7 @@ pub fn view(model: Model) { artists_view.render(library_if_loaded(model), model.settings, [ attribute.id("artists-view"), attribute.class("glass-bg"), + start_play.on(StartPlay), ]) library_view.Albums -> albums_view.render(library_if_loaded(model), model.settings, [ diff --git a/src/elekf/web/components/library_view.gleam b/src/elekf/web/components/library_view.gleam index 212ad5d..6248f4c 100644 --- a/src/elekf/web/components/library_view.gleam +++ b/src/elekf/web/components/library_view.gleam @@ -8,10 +8,12 @@ import gleam/dict import gleam/option import gleam/string import gleam/result +import gleam/int +import gleam/set import lustre import lustre/effect -import lustre/element.{text} -import lustre/element/html.{div} +import lustre/element.{type Element, text} +import lustre/element/html.{div, h1, p} import lustre/attribute import lustre/event import elekf/utils/lustre as lustre_utils @@ -23,6 +25,7 @@ import elekf/web/events/scroll_to import elekf/web/components/search import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/shuffle_all +import elekf/web/components/track_length import elekf/web/common import elekf/web/storage/history/storage as history_store @@ -30,6 +33,11 @@ import elekf/web/storage/history/storage as history_store pub type DataGetter(a, filter) = fn(Library, filter) -> List(LibraryItem(a)) +/// A view to render the header of the library view. +pub type HeaderView(a, filter) = + fn(Model(a, filter), Library, filter, List(LibraryItem(a))) -> + List(Element(Msg(filter))) + /// A view that renders a single item from the library. /// /// It should return a list of elements, which can be used for expanding items: @@ -37,19 +45,12 @@ pub type DataGetter(a, filter) = /// expanded contents. pub type ItemView(a, filter) = fn(Model(a, filter), Library, List(LibraryItem(a)), Int, LibraryItem(a)) -> - List(element.Element(Msg(filter))) + List(Element(Msg(filter))) -/// A filter to restrict the items to be shown. -pub type Filter(filter) { - /// The filter has not yet been obtained as the component is initializing. No - /// data fetches should be done yet. - FilterNotLoaded - Filter(filter) -} - -pub type LibraryAcquired - -pub type LibraryNotAcquired +/// A filter that might not yet be loaded. If it's not loaded, no data should be +/// fetched yet. +pub type Filter(filter) = + option.Option(filter) pub opaque type LibraryStatus { HaveLibrary(library: Library) @@ -122,17 +123,19 @@ pub type Model(a, filter) { pub fn register( name: String, data_getter: DataGetter(a, filter), + header_view: HeaderView(a, filter), item_view: ItemView(a, filter), shuffler: Shuffler(a), sorter: Sorter(a), + filter: Filter(filter), search_filter: SearchFilter(a), extra_attrs: dict.Dict(String, dynamic.Decoder(Msg(filter))), ) { lustre.component( name, - fn() { init(name, FilterNotLoaded, data_getter, shuffler, sorter) }, + fn() { init(name, filter, data_getter, shuffler, sorter) }, update, - generate_view(item_view, search_filter), + generate_view(header_view, item_view, search_filter), dict.merge(generic_attributes(), extra_attrs), ) } @@ -220,7 +223,7 @@ pub fn update(model: Model(a, filter), msg) { } FilterUpdated(filter) -> { - let new_model = Model(..model, filter: Filter(filter)) + let new_model = Model(..model, filter: option.Some(filter)) #(update_data(new_model), effect.none()) } @@ -245,11 +248,12 @@ pub fn update(model: Model(a, filter), msg) { pub fn library_view( model: Model(a, filter), + header_view: HeaderView(a, filter), item_view: ItemView(a, filter), search_filter: SearchFilter(a), ) { - case model.library_status, model.filter == FilterNotLoaded { - HaveLibrary(lib), False -> { + case model.library_status, model.filter { + HaveLibrary(lib), option.Some(f) -> { let items = case model.search.search_text { "" -> model.data txt -> { @@ -264,13 +268,17 @@ pub fn library_view( list.append( [ search.view(model.search) - |> element.map(Search), + |> element.map(Search), + div( + [attribute.id("library-list-header")], + header_view(model, lib, f, items), + ), shuffle_all.view([event.on_click(ShuffleAll)]), ], list.index_map(items, fn(item, i) { - item_view(model, lib, items, i, item) - }) - |> list.flatten(), + item_view(model, lib, items, i, item) + }) + |> list.flatten(), ), ) } @@ -282,8 +290,61 @@ pub fn library_view( } } -fn generate_view(item_view: ItemView(a, filter), search_filter: SearchFilter(a)) { - library_view(_, item_view, search_filter) +/// An empty header view that renders nothing. +pub fn empty_header(_model, _library, _filter, _items) { + [] +} + +/// A header that renders some statistics about the found tracks. +pub fn stats_header( + title: String, + _model, + _library, + _filter, + items: List(LibraryItem(Track)), +) { + let #(tracks, seconds, artists, albums) = + list.fold(items, #(0, 0, set.new(), set.new()), fn(acc, item) { + let #(_i, track) = item + let #(tracks, seconds, artists, albums) = acc + #( + tracks + 1, + seconds + track.length, + set.insert(artists, track.artist_id), + set.insert(albums, track.album_id), + ) + }) + let duration = + track_length.humanize_length( + seconds, + track_length.Auto, + track_length.short_delimiters, + ) + + [ + div([attribute.id("library-list-stats-header")], [ + h1([], [text(title)]), + p([], [ + text( + int.to_string(tracks) + <> " tracks by " + <> int.to_string(set.size(artists)) + <> " artists in " + <> int.to_string(set.size(albums)) + <> " albums, " + <> duration, + ), + ]), + ]), + ] +} + +fn generate_view( + header_view: HeaderView(a, filter), + item_view: ItemView(a, filter), + search_filter: SearchFilter(a), +) { + library_view(_, header_view, item_view, search_filter) } fn shuffle_all(model: Model(a, filter)) { @@ -311,7 +372,7 @@ fn settings_decode(data: dynamic.Dynamic) { fn update_data(model: Model(a, filter)) { case model.library_status, model.filter { - HaveLibrary(l), Filter(f) -> + HaveLibrary(l), option.Some(f) -> Model( ..model, data: l diff --git a/src/elekf/web/components/library_views/albums_view.gleam b/src/elekf/web/components/library_views/albums_view.gleam index c84d35b..77ba382 100644 --- a/src/elekf/web/components/library_views/albums_view.gleam +++ b/src/elekf/web/components/library_views/albums_view.gleam @@ -20,9 +20,11 @@ pub fn register() { library_view.register( component_name, data_getter, + header_view, item_view, shuffler, album_utils.sort_by_name, + option.Some(Nil), search_filter, dict.new(), ) @@ -52,6 +54,20 @@ fn search_filter(item: Album, search_text: String) { string.contains(item.name_lower, search_text) } +fn header_view( + model: Model(Album, Nil), + library: Library, + filter: Nil, + items: List(LibraryItem(Album)), +) { + let tracks = + list.fold(items, [], fn(acc, artist) { + list.concat([acc, album_utils.get_tracks(library, artist.1)]) + }) + + library_view.stats_header("Albums", model, library, filter, tracks) +} + fn item_view( model: Model(Album, Nil), library: Library, diff --git a/src/elekf/web/components/library_views/artists_view.gleam b/src/elekf/web/components/library_views/artists_view.gleam index edb2d6e..a035592 100644 --- a/src/elekf/web/components/library_views/artists_view.gleam +++ b/src/elekf/web/components/library_views/artists_view.gleam @@ -25,9 +25,11 @@ pub fn register() { library_view.register( component_name, data_getter, + header_view, item_view, shuffler, artist_utils.sort_by_name, + option.Some(Nil), search_filter, dict.new(), ) @@ -63,6 +65,20 @@ fn search_filter(item: Artist, search_text: String) { string.contains(item.name_lower, search_text) } +fn header_view( + model: Model(Artist, Nil), + library: Library, + filter: Nil, + items: List(LibraryItem(Artist)), +) { + let tracks = + list.fold(items, [], fn(acc, artist) { + list.concat([acc, get_tracks(library, artist.1)]) + }) + + library_view.stats_header("Artists", model, library, filter, tracks) +} + fn item_view( model: Model(Artist, Nil), _library: Library, diff --git a/src/elekf/web/components/library_views/play_queue_view.gleam b/src/elekf/web/components/library_views/play_queue_view.gleam index 913eb40..13f9fa7 100644 --- a/src/elekf/web/components/library_views/play_queue_view.gleam +++ b/src/elekf/web/components/library_views/play_queue_view.gleam @@ -20,9 +20,11 @@ pub fn register() { library_view.register( component_name, data_getter, + library_view.empty_header, item_view, shuffler, order.noop, + option.None, search_filter, dict.new(), ) diff --git a/src/elekf/web/components/library_views/single_album_view.gleam b/src/elekf/web/components/library_views/single_album_view.gleam index 1d91b3b..7581d9c 100644 --- a/src/elekf/web/components/library_views/single_album_view.gleam +++ b/src/elekf/web/components/library_views/single_album_view.gleam @@ -5,7 +5,10 @@ import gleam/list import gleam/dict import gleam/option import gleam/dynamic +import gleam/int import lustre/attribute +import lustre/element.{text} +import lustre/element/html.{div, h1, p} import elekf/library.{type Library} import elekf/library/track.{type Track} import elekf/library/track_utils @@ -13,6 +16,7 @@ import elekf/web/components/library_view import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/library_views/track_item import elekf/web/common +import elekf/web/components/track_length const component_name = "single-album-view" @@ -21,9 +25,11 @@ pub fn register() { library_view.register( component_name, data_getter, + header_view, item_view, shuffler, track_utils.sort_by_track_number, + option.None, search_filter, dict.from_list([#("album-id", id_decode)]), ) @@ -61,6 +67,38 @@ fn search_filter(item: Track, search_text: String) { string.contains(item.title_lower, search_text) } +fn header_view( + model: library_view.Model(Track, Int), + library: Library, + filter: Int, + items: List(LibraryItem(Track)), +) { + case library.get_album(library, filter) { + Ok(album) -> { + let #(tracks, seconds) = + list.fold(items, #(0, 0), fn(acc, item) { + let #(_i, track) = item + let #(tracks, seconds) = acc + #(tracks + 1, seconds + track.length) + }) + let duration = + track_length.humanize_length( + seconds, + track_length.Auto, + track_length.short_delimiters, + ) + + [ + div([attribute.id("library-list-stats-header")], [ + h1([], [text(album.name)]), + p([], [text(int.to_string(tracks) <> " tracks, " <> duration)]), + ]), + ] + } + Error(_) -> library_view.empty_header(model, library, filter, items) + } +} + fn item_view( _model: library_view.Model(Track, Int), library: Library, 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 8443461..853f830 100644 --- a/src/elekf/web/components/library_views/single_artist_view.gleam +++ b/src/elekf/web/components/library_views/single_artist_view.gleam @@ -5,7 +5,11 @@ import gleam/list import gleam/dict import gleam/option import gleam/dynamic +import gleam/set +import gleam/int import lustre/attribute +import lustre/element.{text} +import lustre/element/html.{div, h1, p} import elekf/library.{type Library} import elekf/library/track.{type Track} import elekf/library/track_utils @@ -13,6 +17,7 @@ import elekf/web/components/library_view import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/library_views/track_item import elekf/web/common +import elekf/web/components/track_length const component_name = "single-artist-view" @@ -21,9 +26,11 @@ pub fn register() { library_view.register( component_name, data_getter, + header_view, item_view, shuffler, track_utils.sort_by_name, + option.None, search_filter, dict.from_list([#("artist-id", id_decode)]), ) @@ -42,8 +49,8 @@ pub fn render( ]) } -fn data_getter(library: Library, artist_id: Int) { - let artist = library.assert_artist(library, artist_id) +fn data_getter(library: Library, filter: Int) { + let artist = library.assert_artist(library, filter) list.map(artist.tracks, fn(track_id) { #(track_id, library.assert_track(library, track_id)) }) @@ -58,6 +65,50 @@ fn search_filter(item: Track, search_text: String) { string.contains(item.title_lower, search_text) } +fn header_view( + model: library_view.Model(Track, Int), + library: Library, + filter: Int, + items: List(LibraryItem(Track)), +) { + case library.get_artist(library, filter) { + Ok(artist) -> { + let #(tracks, seconds, albums) = + list.fold(items, #(0, 0, set.new()), fn(acc, item) { + let #(_i, track) = item + let #(tracks, seconds, albums) = acc + #( + tracks + 1, + seconds + track.length, + set.insert(albums, track.album_id), + ) + }) + let duration = + track_length.humanize_length( + seconds, + track_length.Auto, + track_length.short_delimiters, + ) + + [ + div([attribute.id("library-list-stats-header")], [ + h1([], [text(artist.name)]), + p([], [ + text( + int.to_string(tracks) + <> " tracks in " + <> int.to_string(set.size(albums)) + <> " albums, " + <> duration, + ), + ]), + ]), + ] + } + Error(_) -> library_view.empty_header(model, library, filter, items) + } +} + fn item_view( _model: library_view.Model(Track, Int), library: Library, diff --git a/src/elekf/web/components/library_views/tracks_view.gleam b/src/elekf/web/components/library_views/tracks_view.gleam index e309704..c70726f 100644 --- a/src/elekf/web/components/library_views/tracks_view.gleam +++ b/src/elekf/web/components/library_views/tracks_view.gleam @@ -20,9 +20,11 @@ pub fn register() { library_view.register( component_name, data_getter, + fn(m, l, f, i) { library_view.stats_header("Tracks", m, l, f, i) }, item_view, shuffler, track_utils.sort_by_name, + option.Some(Nil), search_filter, dict.new(), ) diff --git a/src/elekf/web/components/player/track.gleam b/src/elekf/web/components/player/track.gleam index 0370497..f7d5678 100644 --- a/src/elekf/web/components/player/track.gleam +++ b/src/elekf/web/components/player/track.gleam @@ -3,6 +3,7 @@ import gleam/int import gleam/dynamic +import lustre/element.{text} import lustre/element/html.{input, p} import lustre/attribute import lustre/event @@ -27,7 +28,7 @@ pub fn view(model: model.Model) { attribute.class("player-time"), attribute.attribute("aria-label", "Time elapsed"), ], - [track_length(model.position, current_time_padding)], + [text(track_length(model.position, current_time_padding))], ), p( [ @@ -35,7 +36,7 @@ pub fn view(model: model.Model) { attribute.class("player-time"), attribute.attribute("aria-label", "Total time"), ], - [track_length(model.track.length, track_length.Auto)], + [text(track_length(model.track.length, track_length.Auto))], ), input([ attribute.id("player-track"), diff --git a/src/elekf/web/components/search.gleam b/src/elekf/web/components/search.gleam index 7ed11ae..64c364e 100644 --- a/src/elekf/web/components/search.gleam +++ b/src/elekf/web/components/search.gleam @@ -1,6 +1,7 @@ //// The search view renders a search bar, other views must do the searching //// based on the emitted messages. +import gleam/dynamic import lustre/element/html.{div, input} import lustre/attribute import lustre/event @@ -50,6 +51,7 @@ pub fn view(model: Model) { attribute.type_("search"), attribute.placeholder("Search"), attribute.attribute("aria-label", "Search through content"), + attribute.value(dynamic.from(model.search_text)), event.on_input(UpdateSearch), ]), ]) diff --git a/src/elekf/web/components/shuffle_all.gleam b/src/elekf/web/components/shuffle_all.gleam index f2aa678..f33904a 100644 --- a/src/elekf/web/components/shuffle_all.gleam +++ b/src/elekf/web/components/shuffle_all.gleam @@ -1,16 +1,11 @@ -import lustre/element/html.{div, h3} +import lustre/element/html.{div} import lustre/element.{text} import lustre/attribute import elekf/web/components/icon pub fn view(extra_attrs: List(attribute.Attribute(a))) { div( - [attribute.class("library-item library-list-shuffle-all")], - [ - h3( - [attribute.class("library-item-title"), ..extra_attrs], - [icon.icon("shuffle", icon.Hidden), text(" Shuffle all")], - ), - ], + [attribute.class("library-item library-list-shuffle-all"), ..extra_attrs], + [icon.icon("shuffle", icon.Hidden), text(" Shuffle all")], ) } diff --git a/src/elekf/web/components/track_length.gleam b/src/elekf/web/components/track_length.gleam index 5ab7802..0525ff8 100644 --- a/src/elekf/web/components/track_length.gleam +++ b/src/elekf/web/components/track_length.gleam @@ -1,6 +1,5 @@ import gleam/int import gleam/string -import lustre/element.{text} /// Use to select padding mode. pub type PadTo { @@ -10,17 +9,45 @@ pub type PadTo { Hours } -/// Get a track length (in seconds) as a formatted time duration. -pub fn track_length(length: Int, padding: PadTo) { - text(humanize_length(length, padding)) +/// Delimiters to use when humanizing a track length. +pub type Delimiters { + Delimiters(hours: String, minutes: String, seconds: String) } -fn humanize_length(length: Int, padding: PadTo) -> String { +/// Colons delimiting the duration, in the form of `1:23:45`. +pub const colon_delimiters = Delimiters(hours: ":", minutes: ":", seconds: "") + +/// Short units delimiting the duration, in the form of `1 h 23 min 45 s.`. +pub const short_delimiters = Delimiters( + hours: " h ", + minutes: " min ", + seconds: " s", +) + +/// Get a track length (in seconds) as a formatted time duration with colon +/// delimiters. +pub fn track_length(length: Int, padding: PadTo) { + humanize_length(length, padding, colon_delimiters) +} + +/// Get a track length (in seconds) as a formatted time duration. +pub fn humanize_length( + length: Int, + padding: PadTo, + delimiters: Delimiters, +) -> String { case length { l if l < 60 -> { case padding { - Auto -> "00:" <> pad_00(length) - Hours -> "0:00:" <> pad_00(length) + Auto -> + "00" <> delimiters.minutes <> pad_00(length) <> delimiters.seconds + Hours -> + "0" + <> delimiters.hours + <> "00" + <> delimiters.minutes + <> pad_00(length) + <> delimiters.seconds } } l if l < 3600 -> { @@ -29,17 +56,26 @@ fn humanize_length(length: Int, padding: PadTo) -> String { let prefix = case padding { Auto -> "" - Hours -> "0:" + Hours -> "0" <> delimiters.hours } - prefix <> pad_00(mins) <> ":" <> pad_00(secs) + prefix + <> pad_00(mins) + <> delimiters.minutes + <> pad_00(secs) + <> delimiters.seconds } l -> { let hours = l / 3600 let hours_secs = hours * 3600 let mins = { l - hours_secs } / 60 let secs = l - hours_secs - mins * 60 - int.to_string(hours) <> ":" <> pad_00(mins) <> ":" <> pad_00(secs) + int.to_string(hours) + <> delimiters.hours + <> pad_00(mins) + <> delimiters.minutes + <> pad_00(secs) + <> delimiters.seconds } } } diff --git a/src/elekf/web/login_view.gleam b/src/elekf/web/login_view.gleam index 9ec10ed..9df78d8 100644 --- a/src/elekf/web/login_view.gleam +++ b/src/elekf/web/login_view.gleam @@ -63,6 +63,9 @@ pub fn update(model, msg) { ), effect.none(), ) + _ -> { + panic as { "Login view got unknown message: " <> string.inspect(msg) } + } } } @@ -91,8 +94,8 @@ pub fn view(model: Model) { attribute.type_("submit"), attribute.disabled( model.logging_in - || model.device_name == "" - || model.login_token == "", + || model.device_name == "" + || model.login_token == "", ), ], [text("Log in")], diff --git a/src/search_ffi.mjs b/src/search_ffi.mjs index 1caefd8..aa3e129 100644 --- a/src/search_ffi.mjs +++ b/src/search_ffi.mjs @@ -4,4 +4,6 @@ export function getElem(id) { export function focus(elem) { elem.focus(); + elem.selectionStart = elem.value.length; + elem.selectionEnd = elem.value.length; } diff --git a/style.css b/style.css index d569cd2..598a149 100644 --- a/style.css +++ b/style.css @@ -416,13 +416,31 @@ single-album-view { padding-bottom: 0; } +#library-list-header { + grid-column: 1 / -1; +} + +#library-list-header h1 { + font-weight: 100; + margin: var(--double-margin) 0; + overflow-x: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; +} + +#library-list-header p { + font-size: 1.2rem; + margin: var(--side-margin) 0; +} + #authed-view-wrapper[data-player-status="open"] #library-list { padding-bottom: 100vh; } #artists-view #library-list, #albums-view #library-list, -#single-artist-view #library-list { +#single-artist-view #library-list, +#tracks-view #library-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-auto-flow: dense; @@ -434,14 +452,15 @@ single-album-view { padding: var(--double-margin); } -.track-item { - padding-left: var(--side-margin); - padding-right: var(--side-margin); +#tracks-view #library-list { + grid-template-columns: 1fr; } .library-list-shuffle-all { grid-column: 1 / -1; - padding: var(--double-margin) var(--side-margin); + padding: var(--double-margin) 0; + font-size: 1.4rem; + font-weight: bold; } .library-item-thumbnail {