Dig the project out form naphthalene, bump deps, update to Lustre v4

This commit is contained in:
Mikko Ahlroth 2024-08-24 00:16:29 +03:00
parent eb30b0f971
commit 8570d4e39e
37 changed files with 760 additions and 571 deletions

View file

@ -1,2 +1,2 @@
gleam 1.0.0
nodejs 20.10.0
gleam 1.4.1
nodejs 22.4.1

82
common.css Normal file
View file

@ -0,0 +1,82 @@
@import "./normalize.css";
@import "./vendor/bootstrap-icons/bootstrap-icons.min.css";
@import "./settings.css";
.glass-bg,
.glass-button,
.glass-input {
background: var(--glass-background);
}
.glass-button:active {
background: rgba(255, 255, 255, var(--glass-opacity-active));
}
.glass-shadow,
.glass-button,
.glass-input {
box-shadow: var(--glass-shadow);
}
.glass-blur,
.glass-button,
.glass-input {
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
}
.glass-border,
.glass-button,
.glass-input {
border: var(--glass-border);
}
.glass-button {
border-radius: var(--button-border-radius);
text-align: center;
}
.button-small,
.glass-input {
border-radius: var(--button-border-radius-small);
}
.glass-button,
.glass-input {
padding: var(--side-margin);
}
.button-group {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: stretch;
}
.button-group > * {
flex: 1;
}
.button-group .glass-button {
border-radius: 0;
border-right: none;
}
.button-group .glass-button:first-child {
border-radius: var(--button-border-radius) 0 0 var(--button-border-radius);
}
.button-group .glass-button:last-child {
border-radius: 0 var(--button-border-radius) var(--button-border-radius) 0;
border-right: var(--glass-border);
}
.hidden {
display: none;
}
a {
color: var(--text-color);
text-decoration: none;
}

View file

@ -1,7 +1,7 @@
name = "elektrofoni"
version = "1.0.0"
target = "javascript"
gleam = ">= 0.33.0"
gleam = ">= 1.4.0"
# Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager.
@ -12,15 +12,15 @@ gleam = ">= 0.33.0"
# links = [{ title = "Website", href = "https://gleam.run" }]
[dependencies]
gleam_stdlib = "~> 0.34"
gleam_json = "~> 0.7"
gleam_stdlib = "0.40.0"
gleam_json = "~> 1.0"
gleam_http = "~> 3.5"
gleam_javascript = "~> 0.7"
gleam_fetch = "~> 0.3"
plinth = "~> 0.1"
varasto = "~> 2.0"
lustre = "~> 3.1"
birl = "~> 1.3"
gleam_javascript = "0.11.0"
gleam_fetch = "~> 1.0"
plinth = "0.4.12"
varasto = ">= 3.0.1 and < 4.0.0"
lustre = "~> 4.3"
birl = "~> 1.7"
[dev-dependencies]
gleeunit = "~> 1.0"
gleeunit = "~> 1.2"

View file

@ -26,8 +26,6 @@
<link rel="icon" type="image/png" sizes="16x16" href="./priv/assets/favicon/favicon-16x16.png">
<link rel="manifest" href="./priv/assets/favicon/site.webmanifest">
<link rel="stylesheet" href="./normalize.css" type="text/css">
<link rel="stylesheet" href="./vendor/bootstrap-icons/bootstrap-icons.min.css" type="text/css">
<link rel="stylesheet" href="./style.css" type="text/css">
<script type="module">

View file

@ -2,33 +2,31 @@
# You typically do not need to edit this file
packages = [
{ name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" },
{ 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_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", "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_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" },
{ name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
{ name = "conversation", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "conversation", source = "hex", outer_checksum = "908B46F60444442785A495197D482558AD8B849C3714A38FAA1940358CC8CCCD" },
{ name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
{ name = "gleam_fetch", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "4AE60B21A9A664137A79B1BEB93F751CB27F1DDED4086CA00C0260F5FFACBD80" },
{ name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" },
{ name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" },
{ name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
{ name = "gleam_otp", version = "0.11.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "517FFB679E44AD71D059F3EF6A17BA6EFC8CB94FA174D52E22FB6768CF684D78" },
{ name = "gleam_stdlib", version = "0.40.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86606B75A600BBD05E539EB59FABC6E307EEEA7B1E5865AFB6D980A93BCB2181" },
{ name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
{ name = "lustre", version = "4.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "BED65BD6A23439FB155A5BE166ED3C6BC20DED844FA9BB21840860951BC8E153" },
{ name = "plinth", version = "0.4.12", build_tools = ["gleam"], requirements = ["conversation", "gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "3727B517C05BD4D49A4AF34BA7E63E03CA77B4E8AD2A4DCE60266B28717C1F9A" },
{ name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" },
{ name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
{ name = "varasto", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "CEAB60C17DD9461E14E9DB8E6FF2A76E45ECCF704EC0555CE0398512068B01C1" },
]
[requirements]
birl = { version = "~> 1.3" }
gleam_fetch = { version = "~> 0.3" }
birl = { version = "~> 1.7" }
gleam_fetch = { version = "~> 1.0" }
gleam_http = { version = "~> 3.5" }
gleam_javascript = { version = "~> 0.7" }
gleam_json = { version = "~> 0.7" }
gleam_stdlib = { version = "~> 0.34" }
gleeunit = { version = "~> 1.0" }
lustre = { version = "~> 3.1" }
plinth = { version = "~> 0.1" }
varasto = { version = "~> 2.0" }
gleam_javascript = { version = "0.11.0" }
gleam_json = { version = "~> 1.0" }
gleam_stdlib = { version = "0.40.0" }
gleeunit = { version = "~> 1.2" }
lustre = { version = "~> 4.3" }
plinth = { version = "0.4.12" }
varasto = { version = ">= 3.0.1 and < 4.0.0" }

42
settings.css Normal file
View file

@ -0,0 +1,42 @@
@font-face {
font-family: "Open Sans";
src: url("./priv/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth\,wght.ttf")
format("truetype") tech("variations");
src: url("./priv/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth\,wght.ttf")
format("truetype-variations");
font-weight: 300 800;
font-stretch: 75% 100%;
}
:root {
--font-family: "Open Sans", ui-sans-serif;
--background-gradient-bottom: rgb(131, 58, 180, 1);
--background-gradient-middle: rgba(253, 29, 29, 1);
--background-gradient-top: rgba(252, 176, 69, 1);
--text-color: #123456;
--glass-opacity: 0.5;
--glass-opacity-active: 0.2;
--glass-shadow-opacity: 0.1;
--glass-background: rgba(255, 255, 255, var(--glass-opacity));
--glass-blur: 10px;
--glass-border-color: rgba(255, 255, 255, var(--glass-opacity));
--glass-border: 1px solid var(--glass-border-color);
--glass-shadow: 0 4px 30px rgba(0, 0, 0, var(--glass-shadow-opacity));
--border-radius: 10px;
--button-border-radius-small: calc(var(--border-radius) / 2);
--button-border-radius: var(--border-radius);
--side-margin: 5px;
--double-margin: calc(var(--side-margin) * 2);
--track-thumb-size: 20px;
--track-scale-factor: 4;
--library-top-nav-height: 50px;
--search-size: 1.6rem;
}

View file

@ -1,7 +1,7 @@
import { Ok, Error } from "./gleam.mjs";
export function newEvent(name, data) {
return new CustomEvent(name, { detail: data });
return new CustomEvent(name, { detail: data, composed: true });
}
export function getDetail(e) {

View file

@ -1,10 +1,10 @@
import gleam/list
import gleam/dict.{type Dict}
import ibroadcast/library/library.{type Library as APILibrary} as api_library
import elekf/library.{Library}
import elekf/transfer/album
import elekf/transfer/artist
import elekf/transfer/track
import gleam/dict.{type Dict}
import gleam/list
import ibroadcast/library/library.{type Library as APILibrary} as api_library
/// Converts API library response to library format.
pub fn from(library: APILibrary) {

View file

@ -1,9 +1,9 @@
import gleam/string
import gleam/option
import gleam/float
import gleam/result
import ibroadcast/library/library.{type Track as APITrack}
import elekf/library/track.{Track}
import gleam/float
import gleam/option
import gleam/result
import gleam/string
import ibroadcast/library/library.{type Track as APITrack}
/// Converts API track response to library format.
pub fn from(track: APITrack) {
@ -20,7 +20,7 @@ pub fn from(track: APITrack) {
genre: track.genre,
length: track.length,
album_id: track.album_id,
artwork_id: artwork_id,
artwork_id:,
artist_id: track.artist_id,
enid: track.enid,
uploaded_on: track.uploaded_on,

View file

@ -1,48 +1,49 @@
//// The authed view manages the user's request config and displays the base
//// view for the app.
import gleam/io
import gleam/list
import gleam/option
import gleam/float
import gleam/int
import gleam/result
import gleam/set
import gleam/javascript/promise
import lustre/element.{text}
import lustre/element/html.{div, nav, p}
import lustre/attribute
import lustre/effect
import lustre/event
import birl
import ibroadcast/library/library as library_api
import ibroadcast/authed_request.{type RequestConfig, RequestConfig}
import elekf/web/common.{type PlayQueue}
import elekf/api/base_request_config.{base_request_config}
import elekf/api/history
import elekf/utils/http
import elekf/utils/lustre
import elekf/library.{type Library}
import elekf/library/artist.{type Artist}
import elekf/library/track.{type Track}
import elekf/transfer/library as library_transfer
import elekf/web/router
import elekf/web/components/player
import elekf/web/components/player/model as player_model
import elekf/web/components/player/actions as player_actions
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/components/library_views/single_album_view
import elekf/web/components/button_group
import elekf/web/components/link
import elekf/web/events/start_play
import elekf/web/utils
import elekf/utils/browser
import elekf/utils/http
import elekf/utils/lustre
import elekf/web/common
import elekf/web/components/button_group
import elekf/web/components/icon.{Alt, icon}
import elekf/web/components/library_view
import elekf/web/components/library_views/albums_view
import elekf/web/components/library_views/artists_view
import elekf/web/components/library_views/single_album_view
import elekf/web/components/library_views/single_artist_view
import elekf/web/components/library_views/tracks_view
import elekf/web/components/link
import elekf/web/components/player
import elekf/web/components/player/actions as player_actions
import elekf/web/components/player/model as player_model
import elekf/web/events/start_play
import elekf/web/play_queue
import elekf/web/router
import elekf/web/utils
import elektrofoni
import forbidden_stdlib/list as forbidden_list
import gleam/float
import gleam/int
import gleam/io
import gleam/javascript/promise
import gleam/option
import gleam/result
import gleam/set
import ibroadcast/authed_request.{type RequestConfig, RequestConfig}
import ibroadcast/library/library as library_api
import lustre/attribute
import lustre/effect
import lustre/element.{text}
import lustre/element/html.{div, nav, p}
import lustre/event
/// Status of the current track that is being played.
pub type CurrentTrackStatus {
@ -56,7 +57,7 @@ pub type PlayInfo {
PlayInfo(
track_id: Int,
track: Track,
play_queue: PlayQueue,
play_queue: play_queue.PlayQueue,
play_index: Int,
player: player_model.Model,
current_track_status: CurrentTrackStatus,
@ -83,7 +84,7 @@ pub type Msg {
UpdateAuthData(common.AuthData)
LibraryResult(Result(library_api.ResponseData, http.ResponseError))
PlayerMsg(player.Msg)
StartPlay(PlayQueue, Int)
StartPlay(play_queue.PlayQueue, Int)
Router(router.Msg)
LibraryViewScrollToTopRequested
}
@ -183,7 +184,7 @@ pub fn update(model: Model, msg) {
info.play_index + 1
_ -> info.play_index - 1
}
case list.at(info.play_queue, next_index) {
case forbidden_list.at(info.play_queue, next_index) {
Ok(_) -> {
let #(status, effect) =
handle_start_play(model, info.play_queue, next_index)
@ -337,8 +338,8 @@ pub fn view(model: Model) {
])
}
fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
let assert Ok(#(track_id, track)) = list.at(queue, position)
fn handle_start_play(model: Model, queue: play_queue.PlayQueue, position: Int) {
let assert Ok(#(track_id, track)) = forbidden_list.at(queue, position)
let #(player_model, maybe_init_effect) = case model.play_status {
HasTracks(PlayInfo(player: p, ..)) -> #(p, effect.none())
@ -476,7 +477,7 @@ fn maybe_scroll(current_view: library_view.View, target_view: library_view.View)
event.on("click", fn(_) {
case current_view == target_view {
True -> Ok(LibraryViewScrollToTopRequested)
False -> Error(Nil)
False -> Error([])
}
})
}

View file

@ -0,0 +1,16 @@
import elekf/web/codec/track
import elekf/web/play_queue
import gleam/dynamic
import gleam/json
import gleam/list
pub fn decode(data: dynamic.Dynamic) {
dynamic.list(dynamic.tuple2(dynamic.int, track.decode))(data)
}
pub fn encode(queue: play_queue.PlayQueue) {
list.map(queue, fn(value) {
json.preprocessed_array([json.int(value.0), track.encode(value.1)])
})
|> json.preprocessed_array()
}

View file

@ -0,0 +1,89 @@
import elekf/library/track.{type Track, Track}
import gleam/dynamic
import gleam/json
import gleam/result
pub fn decode(data: dynamic.Dynamic) {
use number <- result.try(dynamic.field("number", dynamic.int)(data))
use year <- result.try(dynamic.field("year", dynamic.int)(data))
use title <- result.try(dynamic.field("title", dynamic.string)(data))
use title_lower <- result.try(dynamic.field("title_lower", dynamic.string)(
data,
))
use genre <- result.try(dynamic.field("genre", dynamic.string)(data))
use length <- result.try(dynamic.field("length", dynamic.int)(data))
use album_id <- result.try(dynamic.field("album_id", dynamic.int)(data))
use artwork_id <- result.try(dynamic.field(
"artwork_id",
dynamic.optional(dynamic.int),
)(data))
use artist_id <- result.try(dynamic.field("artist_id", dynamic.int)(data))
use enid <- result.try(dynamic.field("enid", dynamic.int)(data))
use uploaded_on <- result.try(dynamic.field("uploaded_on", dynamic.string)(
data,
))
use trashed <- result.try(dynamic.field("trashed", dynamic.bool)(data))
use size <- result.try(dynamic.field("size", dynamic.int)(data))
use path <- result.try(dynamic.field("path", dynamic.string)(data))
use uid <- result.try(dynamic.field("uid", dynamic.string)(data))
use rating <- result.try(dynamic.field("rating", dynamic.int)(data))
use plays <- result.try(dynamic.field("plays", dynamic.int)(data))
use file <- result.try(dynamic.field("file", dynamic.string)(data))
use type_ <- result.try(dynamic.field("type", dynamic.string)(data))
use replay_gain <- result.try(dynamic.field("replay_gain", dynamic.float)(
data,
))
use uploaded_time <- result.try(dynamic.field("uploaded_time", dynamic.string)(
data,
))
Ok(Track(
number:,
year:,
title:,
title_lower:,
genre:,
length:,
album_id:,
artwork_id:,
artist_id:,
enid:,
uploaded_on:,
trashed:,
size:,
path:,
uid:,
rating:,
plays:,
file:,
type_:,
replay_gain:,
uploaded_time:,
))
}
pub fn encode(track: Track) {
json.object([
#("number", json.int(track.number)),
#("year", json.int(track.year)),
#("title", json.string(track.title)),
#("title_lower", json.string(track.title_lower)),
#("genre", json.string(track.genre)),
#("length", json.int(track.length)),
#("album_id", json.int(track.album_id)),
#("artwork_id", json.nullable(track.artwork_id, json.int)),
#("artist_id", json.int(track.artist_id)),
#("enid", json.int(track.enid)),
#("uploaded_on", json.string(track.uploaded_on)),
#("trashed", json.bool(track.trashed)),
#("size", json.int(track.size)),
#("path", json.string(track.path)),
#("uid", json.string(track.uid)),
#("rating", json.int(track.rating)),
#("plays", json.int(track.plays)),
#("file", json.string(track.file)),
#("type", json.string(track.type_)),
#("replay_gain", json.float(track.replay_gain)),
#("uploaded_time", json.string(track.uploaded_time)),
])
}

View file

@ -1,10 +1,5 @@
import elekf/library/track.{type Track}
import elekf/api/auth/models as auth_models
/// A queue of tracks to play, with their IDs.
pub type PlayQueue =
List(#(Int, Track))
/// Authentication data for the user.
pub type AuthData {
AuthData(user: auth_models.User, device: auth_models.Device)

View file

@ -2,10 +2,6 @@ import lustre/attribute
import lustre/element
import lustre/element/html.{div}
/// An item in the library with its ID.
pub type LibraryItem(a) =
#(Int, a)
pub fn view(
class: String,
extra_attributes: List(attribute.Attribute(a)),

View file

@ -0,0 +1,107 @@
@import "../../../../settings.css";
@import "../../../../common.css";
#search-bar {
position: absolute;
top: calc(var(--library-top-nav-height) + var(--side-margin) * 3);
right: var(--double-margin);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: stretch;
gap: var(--double-margin);
}
#search-bar button {
border-radius: 100%;
width: calc(var(--search-size) * 2);
height: calc(var(--search-size) * 2);
font-size: var(--search-size);
}
#search-bar-input-wrapper {
flex: 1 0;
}
#search-bar-input-wrapper input {
/* 3 double margins: left side, flex gap, right side */
width: calc(100vw - var(--double-margin) * 3 - var(--search-size) * 2);
height: 100%;
font-size: var(--search-size);
}
#search-bar-input-wrapper input::-webkit-search-cancel-button {
font-size: var(--search-size);
}
.library-header {
padding: var(--side-margin);
overflow-x: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
font-size: 2rem;
}
#library-list {
height: 100%;
overflow-y: auto;
padding-bottom: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-flow: dense;
/* Ensure the grid doesn't stretch when there's only a few elements */
grid-auto-rows: min-content;
gap: var(--double-margin);
padding: var(--double-margin);
}
#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;
}
.library-list-shuffle-all {
grid-column: 1 / -1;
padding: var(--double-margin) 0;
font-size: 1.4rem;
font-weight: bold;
}
.library-item-thumbnail {
aspect-ratio: 1 / 1;
}
.library-item {
cursor: pointer;
}
.library-item:active {
background: var(--glass-background);
}
.library-item h3 {
overflow-wrap: break-word;
}

View file

@ -2,32 +2,34 @@
//// component cannot be used directly, this should be extended by the actual
//// components that focus on a specific type of view to the library.
import gleam/dynamic
import gleam/list
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/utils/lustre as lustre_utils
import elekf/utils/order.{type Sorter}
import elekf/web/common
import elekf/web/components/search
import elekf/web/components/shuffle_all
import elekf/web/components/track_length
import elekf/web/events/scroll_to
import elekf/web/events/start_play
import elekf/web/library_item.{type LibraryItem}
import elekf/web/play_queue
import elekf/web/storage/history/storage as history_store
import forbidden_stdlib/dynamic as forbidden_dynamic
import gleam/dict
import gleam/option
import gleam/string
import gleam/result
import gleam/dynamic
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/set
import gleam/string
import lustre
import lustre/attribute
import lustre/effect
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
import elekf/utils/order.{type Sorter}
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/web/events/start_play
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
/// Function to get the data of the view from the library.
pub type DataGetter(a, filter) =
@ -88,7 +90,7 @@ pub type Msg(filter) {
LibraryUpdated(Library)
SettingsUpdated(option.Option(common.Settings))
ShuffleAll
StartPlay(List(LibraryItem(Track)), Int)
StartPlay(play_queue.PlayQueue, Int)
Search(search.Msg)
FilterUpdated(filter)
ListScrolled(Float)
@ -108,9 +110,12 @@ pub type Model(a, filter) {
settings: option.Option(common.Settings),
history: history_store.StorageFormat,
history_api: history_store.HistoryStorage,
styles_path: String,
)
}
pub const default_styles_path = "./src/elekf/web/components/library_view.css"
/// Register the component as a custom element in the app.
///
/// This must be implemented by the client component, passing suitable values.
@ -130,14 +135,16 @@ pub fn register(
filter: Filter(filter),
search_filter: SearchFilter(a),
extra_attrs: dict.Dict(String, dynamic.Decoder(Msg(filter))),
styles_path: String,
) {
lustre.component(
name,
fn() { init(name, filter, data_getter, shuffler, sorter) },
update,
generate_view(header_view, item_view, search_filter),
dict.merge(generic_attributes(), extra_attrs),
)
let app =
lustre.component(
fn(_) { init(name, filter, data_getter, shuffler, sorter, styles_path) },
update,
generate_view(header_view, item_view, search_filter),
dict.merge(generic_attributes(), extra_attrs),
)
lustre.register(app, name)
}
/// Get the generic properties common to all library views, that can be input
@ -167,7 +174,7 @@ pub fn render(
@external(javascript, "../../../library_view_ffi.mjs", "requestScroll")
pub fn request_scroll(pos: Float) -> Nil
pub fn init(id, filter, data_getter, shuffler, sorter) {
pub fn init(id, filter, data_getter, shuffler, sorter, styles_path) {
let scrollend_effect =
effect.from(fn(dispatch) {
lustre_utils.after_next_render(fn() {
@ -189,17 +196,18 @@ pub fn init(id, filter, data_getter, shuffler, sorter) {
#(
Model(
id,
filter,
NoLibrary,
[],
data_getter,
shuffler,
sorter,
search.init(),
option.None,
history,
history_api,
id:,
filter:,
library_status: NoLibrary,
data: [],
data_getter:,
shuffler:,
sorter:,
search: search.init(),
settings: option.None,
history:,
history_api:,
styles_path:,
),
effect.batch([scrollend_effect, scroll_to_effect]),
)
@ -252,6 +260,13 @@ pub fn library_view(
item_view: ItemView(a, filter),
search_filter: SearchFilter(a),
) {
let styles =
html.link([
attribute.rel("stylesheet"),
attribute.type_("text/css"),
attribute.href(model.styles_path),
])
case model.library_status, model.filter {
HaveLibrary(lib), option.Some(f) -> {
let items = case model.search.search_text {
@ -262,31 +277,39 @@ pub fn library_view(
|> list.filter(fn(item) { search_filter(item.1, search_txt) })
}
}
div(
[attribute.id("library-list"), scroll_to.on(ScrollRequested)],
list.append(
[
search.view(model.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) {
element.fragment([
styles,
div(
[attribute.id("library-list"), scroll_to.on(ScrollRequested)],
list.append(
[
search.view(model.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(),
|> list.flatten(),
),
),
)
])
}
_, _ ->
div(
[attribute.id("library-list"), attribute.class("library-list-loading")],
[text("")],
)
element.fragment([
styles,
div(
[
attribute.id("library-list"),
attribute.class("library-list-loading"),
],
[text("")],
),
])
}
}
@ -351,14 +374,14 @@ fn shuffle_all(model: Model(a, filter)) {
case model.library_status {
HaveLibrary(lib) -> {
let tracks = model.shuffler(lib, model.data)
start_play.emit(tracks, 0)
start_play.emit(play_queue.from_list(tracks), 0)
}
NoLibrary -> effect.none()
}
}
fn library_decode(data: dynamic.Dynamic) {
let library: option.Option(Library) = dynamic.unsafe_coerce(data)
let library: option.Option(Library) = forbidden_dynamic.unsafe_coerce(data)
case library {
option.Some(lib) -> Ok(LibraryUpdated(lib))
option.None -> Error([])
@ -366,7 +389,8 @@ fn library_decode(data: dynamic.Dynamic) {
}
fn settings_decode(data: dynamic.Dynamic) {
let settings: option.Option(common.Settings) = dynamic.unsafe_coerce(data)
let settings: option.Option(common.Settings) =
forbidden_dynamic.unsafe_coerce(data)
Ok(SettingsUpdated(settings))
}
@ -376,8 +400,8 @@ fn update_data(model: Model(a, filter)) {
Model(
..model,
data: l
|> model.data_getter(f)
|> list.sort(fn(a, b) { model.sorter(a.1, b.1) }),
|> model.data_getter(f)
|> list.sort(fn(a, b) { model.sorter(a.1, b.1) }),
)
_, _ -> model
}

View file

@ -1,19 +1,19 @@
//// Library item view for a single album.
import gleam/list
import gleam/option
import gleam/int
import lustre/element/html.{h3, p}
import lustre/element.{text}
import lustre/attribute
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/album_utils
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/thumbnail
import elekf/web/components/link
import elekf/web/common.{type Settings}
import elekf/web/components/link
import elekf/web/components/thumbnail
import elekf/web/library_item.{type LibraryItem}
import elekf/web/router
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{h3, p}
pub fn view(
library: Library,

View file

@ -1,17 +1,17 @@
//// A library view to all of the albums in the library.
import gleam/string
import gleam/list
import gleam/dict
import gleam/option
import lustre/attribute
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/album_utils
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
import elekf/web/components/library_view.{type Model}
import elekf/web/components/library_views/album_item
import elekf/web/library_item.{type LibraryItem}
import gleam/dict
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute
const component_name = "albums-view"
@ -27,6 +27,7 @@ pub fn register() {
option.Some(Nil),
search_filter,
dict.new(),
library_view.default_styles_path,
)
}

View file

@ -1,22 +1,22 @@
//// A library view to all of the artists in the library.
import gleam/string
import gleam/int
import gleam/list
import gleam/dict
import gleam/option
import lustre/element/html.{h3, p}
import lustre/element.{text}
import lustre/attribute
import elekf/library.{type Library}
import elekf/library/artist.{type Artist}
import elekf/library/artist_utils
import elekf/web/components/library_view.{type Model}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/thumbnail
import elekf/web/common
import elekf/web/router
import elekf/web/components/library_view.{type Model}
import elekf/web/components/link
import elekf/web/components/thumbnail
import elekf/web/library_item.{type LibraryItem}
import elekf/web/router
import gleam/dict
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{h3, p}
const component_name = "artists-view"
@ -32,6 +32,7 @@ pub fn register() {
option.Some(Nil),
search_filter,
dict.new(),
library_view.default_styles_path,
)
}

View file

@ -1,17 +1,21 @@
//// 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/codec/play_queue as play_queue_codec
import elekf/web/common
import elekf/web/components/library_view.{type Model}
import elekf/web/components/library_views/track_item
import elekf/web/library_item.{type LibraryItem}
import elekf/web/play_queue.{type PlayQueue}
import gleam/dict
import gleam/dynamic
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import lustre/attribute
const component_name = "play-queue-view"
@ -20,13 +24,14 @@ pub fn register() {
library_view.register(
component_name,
data_getter,
library_view.empty_header,
fn(m, l, f, i) { library_view.stats_header("Play queue", m, l, f, i) },
item_view,
shuffler,
order.noop,
option.None,
search_filter,
dict.new(),
dict.from_list([#("tracks", queue_decoder)]),
library_view.default_styles_path,
)
}
@ -39,8 +44,8 @@ pub fn render(
library_view.render(component_name, library, settings, extra_attrs)
}
fn data_getter(library: Library, _filter: Nil) {
dict.to_list(library.tracks)
fn data_getter(_library: Library, filter: PlayQueue) {
play_queue.to_list(filter)
}
fn shuffler(_library, items) {
@ -52,7 +57,7 @@ fn search_filter(item: Track, search_text: String) {
}
fn item_view(
_model: Model(Track, Nil),
_model: Model(Track, PlayQueue),
library: Library,
items: List(LibraryItem(Track)),
index: Int,
@ -60,3 +65,8 @@ fn item_view(
) {
track_item.view(library, items, index, item, "track-list")
}
fn queue_decoder(queue: dynamic.Dynamic) {
use queue <- result.try(play_queue_codec.decode(queue))
Ok(library_view.FilterUpdated(queue))
}

View file

@ -1,22 +1,23 @@
//// A library view to a single album's tracks.
import gleam/string
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
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/library_view
import elekf/web/components/library_views/track_item
import elekf/web/components/track_length
import elekf/web/library_item.{type LibraryItem}
import gleam/dict
import gleam/dynamic
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{div, h1, p}
const component_name = "single-album-view"
@ -32,6 +33,7 @@ pub fn register() {
option.None,
search_filter,
dict.from_list([#("album-id", id_decode)]),
library_view.default_styles_path,
)
}
@ -110,6 +112,6 @@ fn item_view(
}
fn id_decode(data: dynamic.Dynamic) {
let album_id: Int = dynamic.unsafe_coerce(data)
use album_id <- result.try(dynamic.int(data))
Ok(library_view.FilterUpdated(album_id))
}

View file

@ -1,23 +1,24 @@
//// A library view to a single artist's albums and non-album tracks.
import gleam/string
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
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/library_view
import elekf/web/components/library_views/track_item
import elekf/web/components/track_length
import elekf/web/library_item.{type LibraryItem}
import gleam/dict
import gleam/dynamic
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/set
import gleam/string
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{div, h1, p}
const component_name = "single-artist-view"
@ -33,6 +34,7 @@ pub fn register() {
option.None,
search_filter,
dict.from_list([#("artist-id", id_decode)]),
library_view.default_styles_path,
)
}
@ -120,6 +122,6 @@ fn item_view(
}
fn id_decode(data: dynamic.Dynamic) {
let artist_id: Int = dynamic.unsafe_coerce(data)
use artist_id <- result.try(dynamic.int(data))
Ok(library_view.FilterUpdated(artist_id))
}

View file

@ -1,12 +1,14 @@
import gleam/int
import lustre/attribute
import lustre/event
import lustre/element.{text}
import lustre/element/html.{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_item as library_item_component
import elekf/web/components/library_view.{StartPlay}
import elekf/web/library_item.{type LibraryItem}
import elekf/web/play_queue
import gleam/int
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{h3, p}
import lustre/event
pub fn view(
library: Library,
@ -18,13 +20,14 @@ pub fn view(
let #(track_id, track) = item
let album = library.assert_album(library, track.album_id)
let artist = library.assert_artist(library, track.artist_id)
let queue = play_queue.from_list(items)
[
library_item.view(
library_item_component.view(
"track-item",
[
attribute.id(id_prefix <> "-" <> int.to_string(track_id)),
attribute.type_("button"),
event.on_click(StartPlay(items, index)),
event.on_click(StartPlay(queue, index)),
attribute.attribute("role", "button"),
],
[

View file

@ -0,0 +1,5 @@
@import "../library_view.css";
#library-list {
grid-template-columns: 1fr;
}

View file

@ -1,17 +1,17 @@
//// A library view to all of the tracks in the library.
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/library/track_utils
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
import elekf/web/components/library_view.{type Model}
import elekf/web/components/library_views/track_item
import elekf/web/library_item.{type LibraryItem}
import gleam/dict
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute
const component_name = "tracks-view"
@ -27,6 +27,7 @@ pub fn register() {
option.Some(Nil),
search_filter,
dict.new(),
"./src/elekf/web/components/library_views/tracks_view.css",
)
}

View file

@ -1,17 +1,16 @@
//// A track for visualising the current song's progress and skipping it back
//// and forth, and additional timestamps.
import gleam/int
import gleam/dynamic
import lustre/element.{text}
import lustre/element/html.{input, p}
import lustre/attribute
import lustre/event
import elekf/web/components/track_length.{track_length}
import elekf/web/components/player/model
import elekf/web/components/player/actions.{
Commit, EndUserSkip, Ephemeral, SelectPosition, StartUserSkip,
}
import elekf/web/components/player/model
import elekf/web/components/track_length.{track_length}
import gleam/int
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{input, p}
import lustre/event
pub fn view(model: model.Model) {
let current_time_padding = case model.track.length > 3600 {
@ -19,7 +18,7 @@ pub fn view(model: model.Model) {
False -> track_length.Auto
}
let track_pos = dynamic.from(int.to_string(model.position))
let track_pos = int.to_string(model.position)
[
p(

View file

@ -1,13 +1,12 @@
//// 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
import elekf/web/components/icon.{Alt, icon}
import elekf/web/components/button
import elekf/utils/lustre
import elekf/web/components/button
import elekf/web/components/icon.{Alt, icon}
import lustre/attribute
import lustre/element/html.{div, input}
import lustre/event
const search_bar_input_id = "search-bar-input"
@ -51,7 +50,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)),
attribute.value(model.search_text),
event.on_input(UpdateSearch),
]),
])

View file

@ -1,7 +1,9 @@
import gleam/result
import gleam/dynamic
import lustre/event
import elekf/utils/custom_event.{type CustomEvent}
import forbidden_stdlib/dynamic as forbidden_dynamic
import gleam/dynamic
import gleam/json
import gleam/result
import lustre/event
pub const event_name = "scroll-to"
@ -10,14 +12,14 @@ pub type EventData {
}
pub fn emit(pos: Float) {
event.emit(event_name, EventData(pos))
event.emit(event_name, json.float(pos))
}
pub fn on(msg: fn(Float) -> b) {
event.on(event_name, fn(data) {
data
|> decoder
|> result.map(fn(e) { msg(e.pos) })
|> result.map(fn(pos) { msg(pos) })
})
}
@ -26,16 +28,14 @@ pub fn js_custom_event(pos: Float) {
}
fn decoder(data: dynamic.Dynamic) {
let e: CustomEvent = dynamic.unsafe_coerce(data)
use detail <- result.try(custom_event.get_detail(e))
use event_data <- result.try(
data_decoder()(detail)
|> result.nil_error(),
let e: CustomEvent = forbidden_dynamic.unsafe_coerce(data)
use detail <- result.try(
custom_event.get_detail(e)
|> result.replace_error([
dynamic.DecodeError("event detail", "no detail", []),
]),
)
use event_data <- result.try(dynamic.float(detail))
Ok(event_data)
}
fn data_decoder() {
dynamic.decode1(EventData, dynamic.field("pos", dynamic.float))
}

View file

@ -1,8 +1,11 @@
import gleam/result
import gleam/dynamic
import lustre/event
import elekf/web/common.{type PlayQueue}
import elekf/utils/custom_event.{type CustomEvent}
import elekf/web/codec/play_queue as play_queue_codec
import elekf/web/play_queue.{type PlayQueue}
import forbidden_stdlib/dynamic as forbidden_dynamic
import gleam/dynamic
import gleam/json
import gleam/result
import lustre/event
const event_name = "start-play"
@ -11,7 +14,13 @@ pub type EventData {
}
pub fn emit(tracks: PlayQueue, position: Int) {
event.emit(event_name, EventData(tracks, position))
event.emit(
event_name,
json.object([
#("queue", play_queue_codec.encode(tracks)),
#("position", json.int(position)),
]),
)
}
pub fn on(msg: fn(PlayQueue, Int) -> b) {
@ -23,11 +32,18 @@ pub fn on(msg: fn(PlayQueue, Int) -> b) {
}
fn decoder(data: dynamic.Dynamic) {
let e: CustomEvent = dynamic.unsafe_coerce(data)
let assert Ok(detail) = custom_event.get_detail(e)
let event_data: EventData =
detail
|> dynamic.unsafe_coerce()
let e: CustomEvent = forbidden_dynamic.unsafe_coerce(data)
use detail <- result.try(
custom_event.get_detail(e)
|> result.replace_error([
dynamic.DecodeError("event detail", "no detail", []),
]),
)
use event_data <- result.try(dynamic.decode2(
EventData,
dynamic.field("queue", play_queue_codec.decode),
dynamic.field("position", dynamic.int),
)(detail))
Ok(event_data)
}

View file

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

View file

@ -0,0 +1,14 @@
import elekf/library/track
import elekf/web/library_item.{type LibraryItem}
/// A queue of tracks to play, with their IDs.
pub type PlayQueue =
List(LibraryItem(track.Track))
pub fn from_list(tracks: List(LibraryItem(track.Track))) -> PlayQueue {
tracks
}
pub fn to_list(queue: PlayQueue) -> List(LibraryItem(track.Track)) {
queue
}

View file

@ -0,0 +1,2 @@
@external(javascript, "../forbidden_stdlib_ffi.mjs", "unsafeCoerce")
pub fn unsafe_coerce(val: a) -> b

View file

@ -0,0 +1,11 @@
pub fn at(list: List(a), index: Int) -> Result(a, Nil) {
do_at(list, index, 0)
}
fn do_at(list: List(a), index: Int, curr: Int) {
case list {
[] -> Error(Nil)
[item, ..] if curr == index -> Ok(item)
[_, ..rest] -> do_at(rest, index, curr + 1)
}
}

View file

@ -0,0 +1,3 @@
export function unsafeCoerce(val) {
return val;
}

View file

@ -4,19 +4,18 @@
////
//// See https://devguide.ibroadcast.com/?p=api#Play-History
import birl
import gleam/dict.{type Dict}
import gleam/json
import gleam/dynamic
import gleam/int
import gleam/javascript/promise
import gleam/json
import gleam/list
import gleam/result
import gleam/dynamic
import gleam/option
import gleam/javascript/promise
import birl
import ibroadcast/request.{DecodeFailed}
import ibroadcast/auth/status
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/http.{type Requestor}
import ibroadcast/auth/status
import ibroadcast/request.{DecodeFailed}
import ibroadcast/request_params
import ibroadcast/servers
import ibroadcast/time
@ -77,15 +76,18 @@ pub fn add_to_day(day: HistoryDay, track_id: String, event: Event) -> HistoryDay
Skip(_) -> day.plays
}
let detail =
dict.update(day.detail, track_id, fn(old_detail) {
case old_detail {
option.Some(old_detail) -> list.append(old_detail, [event])
option.None -> [event]
}
})
let old_detail = dict.get(day.detail, track_id)
HistoryDay(..day, plays: plays, detail: detail)
let detail = case old_detail {
Ok(old_detail) -> list.append(old_detail, [event])
Error(_) -> [event]
}
HistoryDay(
..day,
plays: plays,
detail: dict.insert(day.detail, track_id, detail),
)
}
fn payload_decoder() {
@ -98,16 +100,16 @@ fn day_serializer(day: HistoryDay) -> json.Json {
"day",
json.string(
int.to_string(day.day.year)
<> "-"
<> {
int.to_string(day.day.month)
|> time.pad_time_part()
}
<> "-"
<> {
int.to_string(day.day.date)
|> time.pad_time_part()
},
<> "-"
<> {
int.to_string(day.day.month)
|> time.pad_time_part()
}
<> "-"
<> {
int.to_string(day.day.date)
|> time.pad_time_part()
},
),
),
#(

View file

@ -1,15 +1,15 @@
import gleam/javascript/promise
import gleam/json
import gleam/dict.{type Dict}
import gleam/dynamic
import gleam/result
import gleam/javascript/promise
import gleam/json
import gleam/option
import ibroadcast/servers
import ibroadcast/request.{DecodeFailed}
import gleam/result
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/request_params.{type RequestParams}
import ibroadcast/http.{type Requestor}
import ibroadcast/library_format
import ibroadcast/request.{DecodeFailed}
import ibroadcast/request_params.{type RequestParams}
import ibroadcast/servers
pub type Album {
Album(
@ -189,26 +189,26 @@ fn track_decoder() {
use uploaded_time <- result.try(dynamic.element(19, dynamic.string)(data))
Ok(Track(
number: number,
year: year,
title: title,
genre: genre,
length: length,
album_id: album_id,
artwork_id: artwork_id,
artist_id: artist_id,
enid: enid,
uploaded_on: uploaded_on,
trashed: trashed,
size: size,
path: path,
uid: uid,
rating: rating,
plays: plays,
file: file,
type_: type_,
replay_gain: replay_gain,
uploaded_time: uploaded_time,
number:,
year:,
title:,
genre:,
length:,
album_id:,
artwork_id:,
artist_id:,
enid:,
uploaded_on:,
trashed:,
size:,
path:,
uid:,
rating:,
plays:,
file:,
type_:,
replay_gain:,
uploaded_time:,
))
}
}

237
style.css
View file

@ -1,119 +1,5 @@
@font-face {
font-family: "Open Sans";
src: url("./priv/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth\,wght.ttf")
format("truetype") tech("variations");
src: url("./priv/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth\,wght.ttf")
format("truetype-variations");
font-weight: 300 800;
font-stretch: 75% 100%;
}
:root {
--font-family: "Open Sans", ui-sans-serif;
--background-gradient-bottom: rgb(131, 58, 180, 1);
--background-gradient-middle: rgba(253, 29, 29, 1);
--background-gradient-top: rgba(252, 176, 69, 1);
--text-color: #123456;
--glass-opacity: 0.5;
--glass-opacity-active: 0.2;
--glass-shadow-opacity: 0.1;
--glass-background: rgba(255, 255, 255, var(--glass-opacity));
--glass-blur: 10px;
--glass-border-color: rgba(255, 255, 255, var(--glass-opacity));
--glass-border: 1px solid var(--glass-border-color);
--glass-shadow: 0 4px 30px rgba(0, 0, 0, var(--glass-shadow-opacity));
--border-radius: 10px;
--button-border-radius-small: calc(var(--border-radius) / 2);
--button-border-radius: var(--border-radius);
--side-margin: 5px;
--double-margin: calc(var(--side-margin) * 2);
--track-thumb-size: 20px;
--track-scale-factor: 4;
--library-top-nav-height: 50px;
--search-size: 1.6rem;
}
.glass-bg,
.glass-button,
.glass-input {
background: var(--glass-background);
}
.glass-button:active {
background: rgba(255, 255, 255, var(--glass-opacity-active));
}
.glass-shadow,
.glass-button,
.glass-input {
box-shadow: var(--glass-shadow);
}
.glass-blur,
.glass-button,
.glass-input {
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
}
.glass-border,
.glass-button,
.glass-input {
border: var(--glass-border);
}
.glass-button {
border-radius: var(--button-border-radius);
text-align: center;
}
.button-small,
.glass-input {
border-radius: var(--button-border-radius-small);
}
.glass-button,
.glass-input {
padding: var(--side-margin);
}
.button-group {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: stretch;
}
.button-group > * {
flex: 1;
}
.button-group .glass-button {
border-radius: 0;
border-right: none;
}
.button-group .glass-button:first-child {
border-radius: var(--button-border-radius) 0 0 var(--button-border-radius);
}
.button-group .glass-button:last-child {
border-radius: 0 var(--button-border-radius) var(--button-border-radius) 0;
border-right: var(--glass-border);
}
.hidden {
display: none;
}
@import "./settings.css";
@import "./common.css";
html {
font-family: var(--font-family);
@ -136,11 +22,6 @@ main {
overflow-y: hidden;
}
a {
color: var(--text-color);
text-decoration: none;
}
tracks-view,
albums-view,
artists-view,
@ -364,117 +245,3 @@ single-album-view {
text-align: center;
font-size: 1.5rem;
}
#search-bar {
position: absolute;
top: calc(var(--library-top-nav-height) + var(--side-margin) * 3);
right: var(--double-margin);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: stretch;
gap: var(--double-margin);
}
#search-bar button {
border-radius: 100%;
width: calc(var(--search-size) * 2);
height: calc(var(--search-size) * 2);
font-size: var(--search-size);
}
#search-bar-input-wrapper {
flex: 1 0;
}
#search-bar-input-wrapper input {
/* 3 double margins: left side, flex gap, right side */
width: calc(100vw - var(--double-margin) * 3 - var(--search-size) * 2);
height: 100%;
font-size: var(--search-size);
}
#search-bar-input-wrapper input::-webkit-search-cancel-button {
font-size: var(--search-size);
}
.library-header {
padding: var(--side-margin);
overflow-x: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
font-size: 2rem;
}
#library-list {
height: 100%;
overflow-y: auto;
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,
#tracks-view #library-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-flow: dense;
/* Ensure the grid doesn't stretch when there's only a few elements */
grid-auto-rows: min-content;
gap: var(--double-margin);
padding: var(--double-margin);
}
#tracks-view #library-list {
grid-template-columns: 1fr;
}
.library-list-shuffle-all {
grid-column: 1 / -1;
padding: var(--double-margin) 0;
font-size: 1.4rem;
font-weight: bold;
}
.library-item-thumbnail {
aspect-ratio: 1 / 1;
}
.library-item {
cursor: pointer;
}
.library-item:active {
background: var(--glass-background);
}
.library-item h3 {
overflow-wrap: break-word;
}