This commit is contained in:
Mikko Ahlroth 2023-10-08 12:53:43 +03:00
commit 20ffb24b30
48 changed files with 2140 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.ez
build

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# elektrofoni
[![Package Version](https://img.shields.io/hexpm/v/elektrofoni)](https://hex.pm/packages/elektrofoni)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/elektrofoni/)
## Quick start
```sh
gleam run # Run the project
gleam test # Run the tests
gleam shell # Run an Erlang shell
```
## Installation
If available on Hex this package can be added to your Gleam project:
```sh
gleam add elektrofoni
```
and its documentation can be found at <https://hexdocs.pm/elektrofoni>.

24
gleam.toml Normal file
View file

@ -0,0 +1,24 @@
name = "elektrofoni"
version = "1.0.0"
target = "javascript"
# Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager.
#
# description = ""
# licences = ["Apache-2.0"]
# repository = { type = "github", user = "username", repo = "project" }
# links = [{ title = "Website", href = "https://gleam.run" }]
[dependencies]
gleam_stdlib = "~> 0.31"
lustre = "~> 3.0"
gleam_json = "~> 0.6"
gleam_http = "~> 3.5"
gleam_javascript = "~> 0.6"
gleam_fetch = "~> 0.2"
plinth = "~> 0.1"
varasto = "~> 1.0"
[dev-dependencies]
gleeunit = "~> 0.10"

27
index.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elektrofoni</title>
<link rel="stylesheet" href="./normalize.css" type="text/css">
<link rel="stylesheet" href="./style.css" type="text/css">
<script type="module">
import { main } from "./build/dev/javascript/elektrofoni/elekf/web/main.mjs";
document.addEventListener("DOMContentLoaded", () => {
main();
});
</script>
</head>
<body>
<div data-lustre-app></div>
<noscript>This application requires JavaScript to run.</noscript>
</body>
</html>

26
manifest.toml Normal file
View file

@ -0,0 +1,26 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "gleam_javascript"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" },
{ name = "gleam_http", version = "3.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FAE9AE3EB1CA90C2194615D20FFFD1E28B630E84DACA670B28D959B37BCBB02C" },
{ name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" },
{ name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" },
{ name = "gleam_stdlib", version = "0.31.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6D1BC5B4D4179B9FEE866B1E69FE180AC2CE485AD90047C0B32B2CA984052736" },
{ name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" },
{ name = "lustre", version = "3.0.6", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "2D2BEF77B5966773467574C2DC23A27FAB7C720DEF428E72C610DA1547E7E171" },
{ name = "plinth", version = "0.1.3", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "E81BA6A6CEAFFADBCB85B04DC817A4CDC43AFA7BB6AE56CE0B7C7E66D1C9ADD1" },
{ name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "plinth", "gleam_stdlib"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
]
[requirements]
gleam_fetch = { version = "~> 0.2" }
gleam_http = { version = "~> 3.5" }
gleam_javascript = { version = "~> 0.6" }
gleam_json = { version = "~> 0.6" }
gleam_stdlib = { version = "~> 0.31" }
gleeunit = { version = "~> 0.10" }
lustre = { version = "~> 3.0" }
plinth = { version = "~> 0.1" }
varasto = { version = "~> 1.0" }

289
normalize.css vendored Normal file
View file

@ -0,0 +1,289 @@
/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
/*
Document
========
*/
/**
Use a better box model (opinionated).
*/
*,
::before,
::after {
box-sizing: border-box;
}
html {
/* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.15; /* 1. Correct the line height in all browsers. */
-webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */
-moz-tab-size: 4; /* 3. Use a more readable tab size (opinionated). */
tab-size: 4; /* 3 */
}
/*
Sections
========
*/
body {
margin: 0; /* Remove the margin in all browsers. */
}
/*
Grouping content
================
*/
/**
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
}
/*
Text-level semantics
====================
*/
/**
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr[title] {
text-decoration: underline dotted;
}
/**
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the odd 'em' font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
Tabular data
============
*/
/**
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
}
/*
Forms
=====
*/
/**
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/**
Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
Remove the inner border and padding in Firefox.
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
Restore the focus styles unset by the previous rule.
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
Remove the additional ':invalid' styles in Firefox.
See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737
*/
:-moz-ui-invalid {
box-shadow: none;
}
/**
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
*/
legend {
padding: 0;
}
/**
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/**
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to 'inherit' in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Interactive
===========
*/
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
:root {
line-height: 1.5;
}
h1,
h2,
h3,
h4,
h5,
figure,
p,
ol,
ul {
margin: 0;
}
ol,
ul {
list-style: none;
padding-inline: 0;
}
img {
display: block;
max-inline-size: 100%;
}

38
src/date_ffi.mjs Normal file
View file

@ -0,0 +1,38 @@
import { Ok, Error } from "./gleam.mjs";
function construct(input) {
const d = new Date(input);
if (isNaN(d.valueOf())) {
return new Error(undefined);
} else {
return new Ok(d);
}
}
export const from_iso8601 = construct;
export const from_unix = construct;
export function to_iso8601(date) {
return date.toISOString();
}
export function to_unix(date) {
return date.getTime();
}
export function unix_now() {
return Date.now();
}
export function now() {
return new Date();
}
export function equals(a, b) {
return a.getTime() === b.getTime();
}
export function bigger_than(a, b) {
return a > b;
}

View file

@ -0,0 +1,7 @@
pub type User {
User(id: Int, token: String, session_uuid: String)
}
pub type Device {
Device(name: String)
}

View file

@ -0,0 +1,64 @@
import gleam/dynamic
import gleam/json
import plinth/javascript/storage
import varasto
import elekf/api/auth/models
const storage_key = "__elektrofoni_auth_storage"
pub type StorageFormat {
StorageFormat(user: models.User, device: models.Device)
}
pub type AuthStorage =
varasto.TypedStorage(StorageFormat)
pub fn get() {
let assert Ok(local) = storage.local()
varasto.new(local, reader(), writer())
}
pub fn read(storage: AuthStorage) {
varasto.get(storage, storage_key)
}
pub fn write(storage: AuthStorage, data: StorageFormat) {
varasto.set(storage, storage_key, data)
}
fn reader() {
dynamic.decode2(
StorageFormat,
dynamic.field("user", user_decoder()),
dynamic.field("device", device_decoder()),
)
}
fn writer() {
fn(val: StorageFormat) {
json.object([
#(
"user",
json.object([
#("id", json.int(val.user.id)),
#("token", json.string(val.user.token)),
#("session_uuid", json.string(val.user.session_uuid)),
]),
),
#("device", json.object([#("name", json.string(val.device.name))])),
])
}
}
fn user_decoder() {
dynamic.decode3(
models.User,
dynamic.field("id", dynamic.int),
dynamic.field("token", dynamic.string),
dynamic.field("session_uuid", dynamic.string),
)
}
fn device_decoder() {
dynamic.decode1(models.Device, dynamic.field("name", dynamic.string))
}

View file

@ -0,0 +1,14 @@
import elektrofoni
import ibroadcast/device_info.{DeviceInfo}
import ibroadcast/request.{RequestConfig}
import elekf/utils/navigator
pub fn base_request_config(device_name: String) {
RequestConfig(
app_info: elektrofoni.app_info,
device_info: DeviceInfo(
name: device_name,
user_agent: navigator.user_agent(),
),
)
}

39
src/elekf/library.gleam Normal file
View file

@ -0,0 +1,39 @@
import gleam/map.{Map}
import elekf/library/track.{Track}
import elekf/library/album.{Album}
import elekf/library/artist.{Artist}
pub type Library {
Library(
albums: Map(Int, Album),
artists: Map(Int, Artist),
tracks: Map(Int, Track),
)
}
pub fn get_album(library: Library, id: Int) {
map.get(library.albums, id)
}
pub fn get_artist(library: Library, id: Int) {
map.get(library.artists, id)
}
pub fn get_track(library: Library, id: Int) {
map.get(library.tracks, id)
}
pub fn assert_album(library: Library, id: Int) {
let assert Ok(album) = map.get(library.albums, id)
album
}
pub fn assert_artist(library: Library, id: Int) {
let assert Ok(artist) = map.get(library.artists, id)
artist
}
pub fn assert_track(library: Library, id: Int) {
let assert Ok(track) = map.get(library.tracks, id)
track
}

View file

@ -0,0 +1,11 @@
pub type Album {
Album(
name: String,
tracks: List(Int),
artist_id: Int,
trashed: Bool,
rating: Int,
disc: Int,
year: Int,
)
}

View file

@ -0,0 +1,3 @@
pub type Artist {
Artist(name: String, tracks: List(Int), trashed: Bool, rating: Int)
}

View file

@ -0,0 +1,25 @@
pub type Track {
Track(
number: Int,
year: Int,
title: String,
title_lower: String,
genre: String,
length: Int,
album_id: Int,
artwork_id: Int,
artist_id: Int,
enid: Int,
uploaded_on: String,
trashed: Bool,
size: Int,
path: String,
uid: String,
rating: Int,
plays: Int,
file: String,
type_: String,
replay_gain: String,
uploaded_time: String,
)
}

View file

@ -0,0 +1,14 @@
import ibroadcast/library/library.{Album as APIAlbum}
import elekf/library/album.{Album}
pub fn from(album: APIAlbum) {
Album(
name: album.name,
tracks: album.tracks,
artist_id: album.artist_id,
trashed: album.trashed,
rating: album.rating,
disc: album.disc,
year: album.year,
)
}

View file

@ -0,0 +1,11 @@
import ibroadcast/library/library.{Artist as APIArtist}
import elekf/library/artist.{Artist}
pub fn from(artist: APIArtist) {
Artist(
name: artist.name,
tracks: artist.tracks,
trashed: artist.trashed,
rating: artist.rating,
)
}

View file

@ -0,0 +1,23 @@
import gleam/list
import gleam/map.{Map}
import ibroadcast/library/library.{Library as APILibrary} as api_library
import elekf/library.{Library}
import elekf/transfer/album
import elekf/transfer/artist
import elekf/transfer/track
pub fn from(library: APILibrary) {
let albums = transfer_map(library.albums, album.from)
let artists = transfer_map(library.artists, artist.from)
let tracks = transfer_map(library.tracks, track.from)
Library(albums: albums, artists: artists, tracks: tracks)
}
fn transfer_map(data: Map(Int, a), transferrer: fn(a) -> b) -> Map(Int, b) {
data
|> map.to_list()
|> list.fold(
map.new(),
fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) },
)
}

View file

@ -0,0 +1,29 @@
import gleam/string
import ibroadcast/library/library.{Track as APITrack}
import elekf/library/track.{Track}
pub fn from(track: APITrack) {
Track(
number: track.number,
year: track.year,
title: track.title,
title_lower: string.lowercase(track.title),
genre: track.genre,
length: track.length,
album_id: track.album_id,
artwork_id: track.artwork_id,
artist_id: track.artist_id,
enid: track.enid,
uploaded_on: track.uploaded_on,
trashed: track.trashed,
size: track.size,
path: track.path,
uid: track.uid,
rating: track.rating,
plays: track.plays,
file: track.file,
type_: track.type_,
replay_gain: track.replay_gain,
uploaded_time: track.uploaded_time,
)
}

View file

@ -0,0 +1,51 @@
import gleam/result
import gleam/order.{Eq, Gt, Lt, Order}
import gleam/dynamic.{DecodeError, Dynamic}
pub type Date
@external(javascript, "../../date_ffi.mjs", "from_iso8601")
pub fn from_iso8601(a: String) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "to_iso8601")
pub fn to_iso8601(d: Date) -> String
@external(javascript, "../../date_ffi.mjs", "from_unix")
pub fn from_unix(a: Int) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "to_unix")
pub fn to_unix(a: Date) -> Int
@external(javascript, "../../date_ffi.mjs", "now")
pub fn now() -> Date
@external(javascript, "../../date_ffi.mjs", "unix_now")
pub fn unix_now() -> Int
@external(javascript, "../../date_ffi.mjs", "equals")
pub fn equals(a: Date, b: Date) -> Bool
@external(javascript, "../../date_ffi.mjs", "bigger_than")
pub fn bigger_than(a: Date, b: Date) -> Bool
/// Compare given dates, returning `Gt` if `a` > `b`
pub fn compare(a: Date, b: Date) -> Order {
case equals(a, b) {
True -> Eq
False -> {
case bigger_than(a, b) {
True -> Gt
False -> Lt
}
}
}
}
pub fn decode(value: Dynamic) -> Result(Date, List(DecodeError)) {
use str <- result.try(dynamic.string(value))
use date <- result.try(
from_iso8601(str)
|> result.replace_error([DecodeError("ISO 8601 formatted string", str, [])]),
)
Ok(date)
}

View file

@ -0,0 +1,16 @@
import gleam/fetch
import gleam/http/request
import gleam/javascript/promise
import ibroadcast/request as ibroadcast_request
import ibroadcast/http as ibroadcast_http
pub type ResponseError =
ibroadcast_request.ResponseError(fetch.FetchError)
pub fn requestor() -> ibroadcast_http.Requestor(fetch.FetchError) {
fn(req: request.Request(String)) {
use resp <- promise.try_await(fetch.send(req))
use resp <- promise.try_await(fetch.read_text_body(resp))
promise.resolve(Ok(resp))
}
}

View file

@ -0,0 +1,2 @@
@external(javascript, "../../navigator_ffi.mjs", "userAgent")
pub fn user_agent() -> String

View file

@ -0,0 +1,7 @@
import gleam/option
/// Asserts that an option contains a value, and returns that value.
pub fn assert_some(val: option.Option(a)) -> a {
let assert option.Some(content) = val
content
}

View file

@ -0,0 +1,244 @@
import gleam/io
import gleam/int
import gleam/list
import gleam/option
import gleam/map
import gleam/string
import gleam/javascript/promise
import lustre/element.{text}
import lustre/element/html.{div, h3, p}
import lustre/attribute
import lustre/event
import lustre/effect
import elekf/web/common
import ibroadcast/library/library as library_api
import ibroadcast/authed_request.{RequestConfig}
import elekf/api/base_request_config.{base_request_config}
import elekf/utils/http
import elekf/library.{Library}
import elekf/library/track.{Track}
import elekf/transfer/library as library_transfer
import elekf/web/components/player
import elekf/web/components/search
import elekf/web/utils
pub type Player {
Player(inner: player.Model)
DisabledPlayer
}
pub type Model {
Model(
loading_library: Bool,
library: option.Option(Library),
settings: option.Option(common.Settings),
request_config: RequestConfig,
player: Player,
search: search.Model,
)
}
pub type Msg {
UpdateAuthData(common.AuthData)
LibraryResult(Result(library_api.ResponseData, http.ResponseError))
PlayerMsg(player.Msg)
Search(search.Msg)
StartPlay(Int, Track)
ShuffleAll
}
pub fn init(auth_data: common.AuthData) {
let model =
Model(
loading_library: True,
library: option.None,
settings: option.None,
request_config: form_request_config(auth_data),
player: DisabledPlayer,
search: search.init(),
)
#(model, load_library(model))
}
pub fn update(model: Model, msg) {
case msg {
UpdateAuthData(auth_data) -> {
let new_config = form_request_config(auth_data)
let #(player, player_effect) = case model.player {
Player(player_model) -> {
let #(new_model, e) =
utils.update_child(
player_model,
player.UpdateRequestConfig(new_config),
player.update,
PlayerMsg,
)
#(Player(new_model), e)
}
DisabledPlayer -> #(DisabledPlayer, effect.none())
}
#(
Model(..model, request_config: new_config, player: player),
player_effect,
)
}
LibraryResult(Ok(data)) -> {
let settings =
common.Settings(
artwork_server: data.settings.artwork_server,
streaming_server: data.settings.streaming_server,
)
#(
Model(
..model,
library: option.Some(library_transfer.from(data.library)),
settings: option.Some(settings),
player: Player(player.init(settings, model.request_config)),
),
effect.none(),
)
}
LibraryResult(Error(error)) -> {
io.println_error("Library load failed:")
io.debug(error)
#(model, effect.none())
}
StartPlay(track_id, track) -> {
let assert Player(inner) = model.player
let #(player_model, player_effect) =
utils.update_child(
inner,
player.Play(track_id, track),
player.update,
PlayerMsg,
)
#(Model(..model, player: Player(player_model)), player_effect)
}
PlayerMsg(msg) -> {
let assert Player(inner) = model.player
let #(player_model, player_effect) =
utils.update_child(inner, msg, player.update, PlayerMsg)
#(Model(..model, player: Player(player_model)), player_effect)
}
Search(msg) -> {
let search_model = search.update(model.search, msg)
#(Model(..model, search: search_model), effect.none())
}
ShuffleAll -> {
let assert option.Some(lib) = model.library
let assert Player(inner) = model.player
let tracks =
lib.tracks
|> map.to_list()
|> list.shuffle()
let #(player_model, player_effect) =
utils.update_child(
inner,
player.AddToQueue(tracks),
player.update,
PlayerMsg,
)
#(Model(..model, player: Player(player_model)), player_effect)
}
}
}
pub fn view(model: Model) {
let search_text = string.lowercase(model.search.search_text)
div(
[attribute.id("authed-view-content")],
[
case model.library {
option.None -> p([], [text("Loading library…")])
option.Some(lib) ->
div(
[attribute.class("track-list")],
list.append(
[
div(
[
attribute.class("track-list-shuffle-all"),
event.on_click(ShuffleAll),
],
[h3([attribute.class("track-title")], [text("Shuffle all")])],
),
],
list.map(
map.to_list(lib.tracks)
|> list.filter(fn(track) {
search_text == "" || string.contains(
{ track.1 }.title_lower,
search_text,
)
}),
fn(item) {
let #(id, track) = item
let album = library.assert_album(lib, track.album_id)
let artist = library.assert_artist(lib, track.artist_id)
div(
[
attribute.id("track-list-" <> int.to_string(id)),
attribute.type_("button"),
event.on_click(StartPlay(id, track)),
attribute.attribute("role", "button"),
],
[
h3([attribute.class("track-title")], [text(track.title)]),
p([attribute.class("track-artist")], [text(artist.name)]),
p([attribute.class("track-album")], [text(album.name)]),
],
)
},
),
),
)
},
div(
[attribute.id("search-positioner")],
[
search.view(model.search)
|> element.map(Search),
],
),
case model.player {
Player(inner) ->
div(
[attribute.id("player")],
[
player.view(inner)
|> element.map(PlayerMsg),
],
)
DisabledPlayer -> text("No player")
},
],
)
}
fn load_library(model: Model) {
use dispatch <- effect.from()
library_api.get_library(model.request_config, http.requestor())
|> promise.map(LibraryResult)
|> promise.tap(dispatch)
Nil
}
fn form_request_config(auth_data: common.AuthData) {
RequestConfig(
auth_info: authed_request.AuthInfo(auth_data.user.id, auth_data.user.token),
base_config: base_request_config(auth_data.device.name),
)
}

View file

@ -0,0 +1,9 @@
import elekf/api/auth/models as auth_models
pub type AuthData {
AuthData(user: auth_models.User, device: auth_models.Device)
}
pub type Settings {
Settings(artwork_server: String, streaming_server: String)
}

View file

@ -0,0 +1,172 @@
import gleam/list
import gleam/uri
import lustre/element.{text}
import lustre/element/html.{audio, button, div, p}
import lustre/attribute
import lustre/event
import lustre/effect
import elektrofoni
import elekf/web/common
import elekf/library/track.{Track}
import elekf/utils/date
import ibroadcast/authed_request.{RequestConfig}
import ibroadcast/streaming
pub type PlayInfo {
Playing(track_id: Int, track: Track, url: uri.Uri)
NoTrack
}
pub type Model {
Model(
settings: common.Settings,
play_info: PlayInfo,
play_queue: List(#(Int, Track)),
loading_stream: Bool,
request_config: RequestConfig,
end_callback_registered: Bool,
)
}
pub type Msg {
Play(Int, Track)
AddToQueue(List(#(Int, Track)))
NextTrack
Clear
UpdateRequestConfig(RequestConfig)
}
pub fn init(settings: common.Settings, request_config: RequestConfig) {
Model(settings, NoTrack, [], False, request_config, False)
}
pub fn update(model: Model, msg) {
case msg {
Play(track_id, track) -> {
let register_effect = case model.end_callback_registered {
True -> effect.none()
False -> register_ended_callback()
}
let url = form_url(track_id, track, model)
#(
Model(
..model,
end_callback_registered: True,
play_info: Playing(track_id: track_id, track: track, url: url),
),
register_effect,
)
}
AddToQueue(tracks) -> {
case list.length(tracks) {
length if length > 0 -> {
let #(play_info, tracks) = case model.play_info {
Playing(..) -> #(model.play_info, tracks)
NoTrack -> {
let #([first], tail) = list.split(tracks, 1)
#(
Playing(first.0, first.1, form_url(first.0, first.1, model)),
tail,
)
}
}
#(
Model(..model, play_info: play_info, play_queue: tracks),
effect.none(),
)
}
_ -> #(model, effect.none())
}
}
NextTrack -> {
case model.play_queue {
[] -> #(model, effect.none())
queue -> {
let #([first], tail) = list.split(queue, 1)
#(
Model(
..model,
play_info: Playing(
first.0,
first.1,
form_url(first.0, first.1, model),
),
play_queue: tail,
),
effect.none(),
)
}
}
}
Clear -> #(
Model(..model, play_info: NoTrack, play_queue: []),
effect.none(),
)
UpdateRequestConfig(config) -> #(
Model(..model, request_config: config),
effect.none(),
)
}
}
pub fn view(model: Model) {
let src = case model.play_info {
Playing(url: url, ..) -> uri.to_string(url)
NoTrack -> ""
}
div(
[attribute.id("player-wrapper")],
[
p(
[attribute.id("player-wrapper-track-title")],
[
text(case model.play_info {
Playing(track: track, ..) -> track.title
NoTrack -> "--"
}),
],
),
audio(
[
attribute.id("player-elem"),
attribute.src(src),
attribute.controls(False),
attribute.autoplay(True),
],
[],
),
button(
[
attribute.id("player-clear"),
attribute.type_("button"),
attribute.disabled(model.play_info == NoTrack),
event.on_click(Clear),
],
[text("🛑")],
),
],
)
}
fn form_url(track_id: Int, track: Track, model: Model) {
streaming.form_url(
track.file,
track_id,
model.request_config,
model.settings.streaming_server,
elektrofoni.bitrate,
date.unix_now() + elektrofoni.track_expiry_length,
)
}
fn register_ended_callback() {
use dispatch <- effect.from()
do_register_ended_callback(fn() { dispatch(NextTrack) })
}
@external(javascript, "../../../player_ffi.mjs", "registerEndedCallback")
fn do_register_ended_callback(callback: fn() -> Nil) -> Nil

View file

@ -0,0 +1,51 @@
import lustre/element.{text}
import lustre/element/html.{button, div, input}
import lustre/attribute
import lustre/event
pub type Model {
Model(search_text: String, show_search: Bool)
}
pub type Msg {
ToggleShow
UpdateSearch(String)
}
pub fn init() {
Model(search_text: "", show_search: False)
}
pub fn update(model, msg) {
case msg {
ToggleShow -> Model(..model, show_search: !model.show_search)
UpdateSearch(text) -> Model(..model, search_text: text)
}
}
pub fn view(model: Model) {
let input_type = case model.show_search {
True -> "search"
False -> "hidden"
}
div(
[attribute.id("search-bar")],
[
div(
[attribute.id("search-bar-input-wrapper")],
[
input([
attribute.type_(input_type),
attribute.placeholder("Search"),
event.on_input(UpdateSearch),
]),
],
),
button(
[attribute.type_("button"), event.on_click(ToggleShow)],
[text("🔎")],
),
],
)
}

View file

@ -0,0 +1,128 @@
import gleam/string
import gleam/javascript/promise
import lustre/element.{text}
import lustre/element/html.{button, form, hr, input, p}
import lustre/attribute
import lustre/event
import lustre/effect
import ibroadcast/auth/login_token
import elektrofoni
import elekf/api/base_request_config.{base_request_config}
import elekf/utils/http
pub type Model {
Model(
device_name: String,
login_token: String,
logging_in: Bool,
login_failed: Bool,
error: String,
)
}
pub type Msg {
AttemptLogin
LoginResult(Result(login_token.ResponseData, http.ResponseError))
OnDeviceNameInput(String)
OnLoginTokenInput(String)
}
pub fn init() {
Model("", "", False, False, "")
}
pub fn update(model, msg) {
case msg {
OnDeviceNameInput(name) -> #(
Model(..model, device_name: name),
effect.none(),
)
OnLoginTokenInput(token) -> #(
Model(..model, login_token: token),
effect.none(),
)
AttemptLogin -> #(Model(..model, logging_in: True), start_login(model))
LoginResult(Ok(login_token.Failed)) -> #(
Model(
..model,
login_failed: True,
error: "The credentials were not accepted.",
),
effect.none(),
)
LoginResult(Error(error)) -> #(
Model(
..model,
logging_in: False,
login_failed: True,
error: "An error prevented logging in: " <> string.inspect(error),
),
effect.none(),
)
}
}
pub fn view(model: Model) {
form(
[
attribute.attribute("method", "post"),
attribute.id("login-form"),
event.on_submit(AttemptLogin),
],
[
input([
attribute.id("device-name"),
attribute.name("device-name"),
attribute.placeholder("Device name (i.e. Kalle's Fairphone)"),
event.on_input(OnDeviceNameInput),
]),
input([
attribute.id("login-token"),
attribute.name("login-token"),
attribute.placeholder("Login token"),
event.on_input(OnLoginTokenInput),
]),
button(
[
attribute.type_("submit"),
attribute.disabled(
model.logging_in || model.device_name == "" || model.login_token == "",
),
],
[text("Log in")],
),
case model.login_failed {
True ->
p(
[attribute.class("form-error")],
[text("Logging in failed due to: " <> model.error)],
)
False -> text("")
},
hr([]),
p(
[attribute.class("form-info")],
[
text(
"To log in, obtain your login token from iBroadcast by going to Menu -> Apps.",
),
],
),
],
)
}
fn start_login(model: Model) {
use dispatch <- effect.from()
login_token.log_in(
elektrofoni.app_id,
model.login_token,
base_request_config(model.device_name),
http.requestor(),
)
|> promise.map(LoginResult)
|> promise.tap(dispatch)
Nil
}

177
src/elekf/web/main.gleam Normal file
View file

@ -0,0 +1,177 @@
import gleam/io
import gleam/option
import lustre
import lustre/effect
import lustre/element/html.{div}
import lustre/element
import lustre/attribute
import elekf/web/common
import elekf/web/login_view
import elekf/web/authed_view
import elekf/web/view
import elekf/web/utils
import elekf/api/auth/storage as auth_storage
import elekf/api/auth/models as auth_models
import elekf/utils/option as option_utils
import ibroadcast/auth/login_token
type Model {
Model(
auth_storage: auth_storage.AuthStorage,
auth_data: option.Option(common.AuthData),
current_view: view.View,
login_view: login_view.Model,
authed_view: option.Option(authed_view.Model),
)
}
type Msg {
LoginView(login_view.Msg)
AuthedView(authed_view.Msg)
}
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)
Nil
}
fn init(_) {
let auth_storage = auth_storage.get()
let stored_auth_data = auth_storage.read(auth_storage)
let auth_data = case stored_auth_data {
Ok(storage_format) ->
option.Some(common.AuthData(
user: auth_models.User(
id: storage_format.user.id,
token: storage_format.user.token,
session_uuid: storage_format.user.session_uuid,
),
device: auth_models.Device(name: storage_format.device.name),
))
Error(_) -> option.None
}
let current_view = case auth_data {
option.Some(_) -> view.AuthedView
option.None -> view.LoginView
}
let #(authed_view_model, authed_view_effect) = case auth_data {
option.Some(data) -> {
let #(m, e) = authed_view.init(data)
#(option.Some(m), effect.map(e, AuthedView))
}
option.None -> #(option.None, effect.none())
}
#(
Model(
auth_storage: auth_storage,
auth_data: auth_data,
current_view: current_view,
login_view: login_view.init(),
authed_view: authed_view_model,
),
authed_view_effect,
)
}
fn update(model: Model, msg) {
case msg {
LoginView(login_view.LoginResult(Ok(login_token.AuthData(data)))) -> {
logged_in(model, data.token, data.session)
}
LoginView(login_msg) -> {
let #(login_model, login_effect) =
utils.update_child(
model.login_view,
login_msg,
login_view.update,
LoginView,
)
#(Model(..model, login_view: login_model), login_effect)
}
AuthedView(authed_msg) -> {
case model.authed_view {
option.Some(authed_view) -> {
let #(authed_model, authed_effect) =
utils.update_child(
authed_view,
authed_msg,
authed_view.update,
AuthedView,
)
#(
Model(..model, authed_view: option.Some(authed_model)),
authed_effect,
)
}
option.None -> #(model, effect.none())
}
}
}
}
fn view(model: Model) {
html.main(
[],
[
case model.current_view {
view.LoginView ->
div(
[attribute.id("login-view")],
[
login_view.view(model.login_view)
|> element.map(LoginView),
],
)
view.AuthedView ->
div(
[attribute.id("authed-view")],
[
authed_view.view(option_utils.assert_some(model.authed_view))
|> element.map(AuthedView),
],
)
},
],
)
}
fn logged_in(model: Model, token: String, session: login_token.Session) {
let auth_data =
common.AuthData(
user: auth_models.User(
id: session.user_id,
token: token,
session_uuid: session.session_uuid,
),
device: auth_models.Device(name: model.login_view.device_name),
)
case
auth_storage.write(
model.auth_storage,
auth_storage.StorageFormat(user: auth_data.user, device: auth_data.device),
)
{
Ok(_) -> Nil
Error(err) -> {
io.println_error("Unable to store authentication credentials:")
io.debug(err)
}
}
#(
Model(
..model,
auth_data: option.Some(auth_data),
current_view: view.AuthedView,
),
effect.none(),
)
}

17
src/elekf/web/utils.gleam Normal file
View file

@ -0,0 +1,17 @@
import lustre/effect.{Effect}
/// Update child view of a given view.
///
/// The `model` and `msg` must be the child view's, the `updater` is the child
/// view's `update` function, and the `mapper` maps the child model's emitted
/// effect into the parent model's effect.
pub fn update_child(
model: a,
msg: b,
updater: fn(a, b) -> #(a, Effect(b)),
mapper: fn(b) -> d,
) {
let #(new_model, new_effect) = updater(model, msg)
let new_effect = effect.map(new_effect, mapper)
#(new_model, new_effect)
}

4
src/elekf/web/view.gleam Normal file
View file

@ -0,0 +1,4 @@
pub type View {
LoginView
AuthedView
}

13
src/elektrofoni.gleam Normal file
View file

@ -0,0 +1,13 @@
import ibroadcast/app_info.{AppInfo}
pub const app_name = "elektrofoni"
pub const app_version = "1.0.0"
pub const app_id = 1098
pub const app_info = AppInfo(app_name, app_version)
pub const bitrate = 256
pub const track_expiry_length = 10_800_000

View file

@ -0,0 +1,3 @@
pub type AppInfo {
AppInfo(client: String, version: String)
}

View file

@ -0,0 +1,88 @@
import gleam/dynamic
import gleam/result
import gleam/json
import gleam/javascript/promise
import ibroadcast/request_params.{RequestParams}
import ibroadcast/request.{DecodeFailed, RequestConfig, ResponseError}
import ibroadcast/http.{Requestor}
import ibroadcast/servers
// pub type Settings {
// Settings(streaming_server: String, artwork_server: String)
// }
// pub type User {
// User(id: Int)
// }
pub type Session {
Session(user_id: Int, session_uuid: String)
}
pub type User {
User(id: String, token: String, session: Session)
}
pub type ResponseData {
AuthData(user: User)
Failed
}
pub fn log_in(
app_id: Int,
login_token: String,
config: RequestConfig,
requestor: Requestor(err_type),
) -> promise.Promise(Result(ResponseData, ResponseError(err_type))) {
use resp <- promise.try_await(request.raw_request(
servers.api,
request_params(app_id, login_token),
config,
requestor,
))
promise.resolve(
json.decode(resp.body, log_in_payload_decoder)
|> result.map_error(DecodeFailed),
)
}
fn request_params(app_id: Int, login_token: String) -> RequestParams {
[
#("mode", json.string("login_token")),
#("type", json.string("account")),
#("app_id", json.int(app_id)),
#("login_token", json.string(login_token)),
]
}
fn log_in_payload_decoder(
data: dynamic.Dynamic,
) -> Result(ResponseData, List(dynamic.DecodeError)) {
use result <- result.try(dynamic.field("result", dynamic.bool)(data))
case result {
True -> dynamic.decode1(AuthData, dynamic.field("user", user_decoder))(data)
False -> Ok(Failed)
}
}
fn user_decoder(
data: dynamic.Dynamic,
) -> Result(User, List(dynamic.DecodeError)) {
dynamic.decode3(
User,
dynamic.field("id", dynamic.string),
dynamic.field("token", dynamic.string),
dynamic.field("session", session_decoder),
)(data)
}
fn session_decoder(
data: dynamic.Dynamic,
) -> Result(Session, List(dynamic.DecodeError)) {
dynamic.decode2(
Session,
dynamic.field("user_id", dynamic.int),
dynamic.field("session_uuid", dynamic.string),
)(data)
}

View file

@ -0,0 +1,6 @@
import gleam/json
import ibroadcast/request_params.{RequestParams}
pub fn request_params() -> RequestParams {
[#("mode", json.string("logout"))]
}

View file

@ -0,0 +1,35 @@
import gleam/uri
import gleam/json
import ibroadcast/http.{Requestor}
import ibroadcast/request_params.{RequestParams}
import ibroadcast/request
import ibroadcast/utils
pub type AuthInfo {
AuthInfo(user_id: Int, token: String)
}
pub type RequestConfig {
RequestConfig(base_config: request.RequestConfig, auth_info: AuthInfo)
}
pub fn authed_params(auth_info: AuthInfo) -> RequestParams {
[
#("user_id", json.int(auth_info.user_id)),
#("token", json.string(auth_info.token)),
]
}
pub fn authed_request(
url: uri.Uri,
params: RequestParams,
config: RequestConfig,
requestor: Requestor(err_type),
) {
request.raw_request(
url,
utils.combine_params([authed_params(config.auth_info), params]),
config.base_config,
requestor,
)
}

View file

@ -0,0 +1,3 @@
pub type DeviceInfo {
DeviceInfo(name: String, user_agent: String)
}

View file

@ -0,0 +1,8 @@
import gleam/http/request
import gleam/http/response
import gleam/javascript/promise
/// The HTTP client to use in API calls.
pub type Requestor(err_type) =
fn(request.Request(String)) ->
promise.Promise(Result(response.Response(String), err_type))

View file

@ -0,0 +1,194 @@
import gleam/javascript/promise
import gleam/json
import gleam/map.{Map}
import gleam/dynamic
import gleam/result
import ibroadcast/servers
import ibroadcast/request.{DecodeFailed}
import ibroadcast/authed_request.{RequestConfig}
import ibroadcast/request_params.{RequestParams}
import ibroadcast/http.{Requestor}
import ibroadcast/map_format
pub type Album {
Album(
name: String,
tracks: List(Int),
artist_id: Int,
trashed: Bool,
rating: Int,
disc: Int,
year: Int,
)
}
pub type Artist {
Artist(name: String, tracks: List(Int), trashed: Bool, rating: Int)
}
pub type Track {
Track(
number: Int,
year: Int,
title: String,
genre: String,
length: Int,
album_id: Int,
artwork_id: Int,
artist_id: Int,
enid: Int,
uploaded_on: String,
trashed: Bool,
size: Int,
path: String,
uid: String,
rating: Int,
plays: Int,
file: String,
type_: String,
replay_gain: String,
uploaded_time: String,
)
}
pub type Library {
Library(
albums: Map(Int, Album),
artists: Map(Int, Artist),
tracks: Map(Int, Track),
)
}
pub type Settings {
Settings(artwork_server: String, streaming_server: String)
}
pub type ResponseData {
ResponseData(library: Library, settings: Settings)
}
pub fn get_library(config: RequestConfig, requestor: Requestor(err_type)) {
use resp <- promise.try_await(authed_request.authed_request(
servers.library,
request_params(),
config,
requestor,
))
promise.resolve(
json.decode(resp.body, payload_decoder())
|> result.map_error(DecodeFailed),
)
}
fn request_params() -> RequestParams {
[#("mode", json.string("library"))]
}
fn payload_decoder() {
dynamic.decode2(
ResponseData,
dynamic.field("library", library_decoder()),
dynamic.field("settings", settings_decoder()),
)
}
fn library_decoder() {
dynamic.decode3(
Library,
dynamic.field("albums", albums_decoder()),
dynamic.field("artists", artists_decoder()),
dynamic.field("tracks", tracks_decoder()),
)
}
fn settings_decoder() {
dynamic.decode2(
Settings,
dynamic.field("artwork_server", dynamic.string),
dynamic.field("streaming_server", dynamic.string),
)
}
fn albums_decoder() {
map_format.decoder(map_format.string_int_decoder(), album_decoder())
}
fn album_decoder() {
dynamic.decode7(
Album,
dynamic.element(0, dynamic.string),
dynamic.element(1, dynamic.list(dynamic.int)),
dynamic.element(2, dynamic.int),
dynamic.element(3, dynamic.bool),
dynamic.element(4, dynamic.int),
dynamic.element(5, dynamic.int),
dynamic.element(6, dynamic.int),
)
}
fn artists_decoder() {
map_format.decoder(map_format.string_int_decoder(), artist_decoder())
}
fn artist_decoder() {
dynamic.decode4(
Artist,
dynamic.element(0, dynamic.string),
dynamic.element(1, dynamic.list(dynamic.int)),
dynamic.element(2, dynamic.bool),
dynamic.element(3, dynamic.int),
)
}
fn tracks_decoder() {
map_format.decoder(map_format.string_int_decoder(), track_decoder())
}
fn track_decoder() {
fn(data: dynamic.Dynamic) {
use number <- result.try(dynamic.element(0, dynamic.int)(data))
use year <- result.try(dynamic.element(1, dynamic.int)(data))
use title <- result.try(dynamic.element(2, dynamic.string)(data))
use genre <- result.try(dynamic.element(3, dynamic.string)(data))
use length <- result.try(dynamic.element(4, dynamic.int)(data))
use album_id <- result.try(dynamic.element(5, dynamic.int)(data))
use artwork_id <- result.try(dynamic.element(6, dynamic.int)(data))
use artist_id <- result.try(dynamic.element(7, dynamic.int)(data))
use enid <- result.try(dynamic.element(8, dynamic.int)(data))
use uploaded_on <- result.try(dynamic.element(9, dynamic.string)(data))
use trashed <- result.try(dynamic.element(10, dynamic.bool)(data))
use size <- result.try(dynamic.element(11, dynamic.int)(data))
use path <- result.try(dynamic.element(12, dynamic.string)(data))
use uid <- result.try(dynamic.element(13, dynamic.string)(data))
use rating <- result.try(dynamic.element(14, dynamic.int)(data))
use plays <- result.try(dynamic.element(15, dynamic.int)(data))
use file <- result.try(dynamic.element(16, dynamic.string)(data))
use type_ <- result.try(dynamic.element(17, dynamic.string)(data))
use replay_gain <- result.try(dynamic.element(18, dynamic.string)(data))
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,
))
}
}

View file

@ -0,0 +1,43 @@
import gleam/result
import gleam/map.{Map}
import gleam/int
import gleam/dynamic.{Decoder}
/// This decoder will attempt to decode a gleam `Map` using the provided decoders.
/// If either a key or a value fails to decode, that entry is just ignored.
pub fn decoder(
key_decoder: Decoder(k),
val_decoder: Decoder(v),
) -> Decoder(Map(k, v)) {
fn(data) {
// First decode into a `Map(Dynamic, Dynamic)`
use dynamic_map <- result.map(dynamic.map(dynamic.dynamic, dynamic.dynamic)(
data,
))
// Fold over that dynamic map. The accumulator will be the desired `Map(k, v)`
use map, dyn_key, dyn_val <- map.fold(dynamic_map, map.new())
// Attempt to decode the current value
case key_decoder(dyn_key), val_decoder(dyn_val) {
// If it succeeds insert the new entry
Ok(key), Ok(val) -> map.insert(map, key, val)
// Otherwise just ignore it and carry on
_, _ -> map
}
}
}
/// A decoder that accepts strings that contain integers.
pub fn string_int_decoder() -> Decoder(Int) {
fn(data) {
use strval <- result.try(dynamic.string(data))
int.parse(strval)
|> result.replace_error([
dynamic.DecodeError(
expected: "A string representing an int",
found: strval,
path: [],
),
])
}
}

View file

@ -0,0 +1,45 @@
import gleam/uri
import gleam/result
import gleam/http as gleam_http
import gleam/http/request
import gleam/javascript/promise
import gleam/json
import ibroadcast/utils
import ibroadcast/http.{Requestor}
import ibroadcast/app_info.{AppInfo}
import ibroadcast/device_info.{DeviceInfo}
import ibroadcast/request_params.{RequestParams}
pub type RequestConfig {
RequestConfig(app_info: AppInfo, device_info: DeviceInfo)
}
pub type ResponseError(requestor_err_type) {
RequestFailed(requestor_err_type)
DecodeFailed(json.DecodeError)
}
pub fn base_params(config: RequestConfig) -> RequestParams {
[
#("client", json.string(config.app_info.client)),
#("version", json.string(config.app_info.version)),
#("device_name", json.string(config.device_info.name)),
#("user_agent", json.string(config.device_info.user_agent)),
]
}
pub fn raw_request(
url: uri.Uri,
params: RequestParams,
config: RequestConfig,
requestor: Requestor(err_type),
) {
let assert Ok(req) = request.from_uri(url)
let params = utils.combine_params([base_params(config), params])
let req =
req
|> request.set_body(json.to_string(json.object(params)))
|> request.set_method(gleam_http.Post)
requestor(req)
|> promise.map(fn(r) { result.map_error(r, RequestFailed) })
}

View file

@ -0,0 +1,4 @@
import gleam/json
pub type RequestParams =
List(#(String, json.Json))

View file

@ -0,0 +1,22 @@
import gleam/option.{None, Some}
import gleam/uri.{Uri}
pub const api = Uri(
scheme: Some("https"),
userinfo: None,
host: Some("api.ibroadcast.com"),
port: Some(443),
path: "/",
query: None,
fragment: None,
)
pub const library = Uri(
scheme: Some("https"),
userinfo: None,
host: Some("library.ibroadcast.com"),
port: Some(443),
path: "/",
query: None,
fragment: None,
)

View file

@ -0,0 +1,32 @@
import gleam/string
import gleam/int
import gleam/uri
import gleam/option
import ibroadcast/authed_request.{RequestConfig}
pub fn form_url(
track_file: String,
track_id: Int,
config: RequestConfig,
server: String,
bitrate: Int,
expires: Int,
) {
let assert Ok(url) = uri.parse(server)
uri.Uri(
..url,
path: string.replace(
track_file,
"/128/",
"/" <> int.to_string(bitrate) <> "/",
),
query: option.Some(uri.query_to_string([
#("Expires", int.to_string(expires)),
#("Signature", config.auth_info.token),
#("file_id", int.to_string(track_id)),
#("user_id", int.to_string(config.auth_info.user_id)),
#("platform", config.base_config.app_info.client),
#("version", config.base_config.app_info.version),
])),
)
}

View file

@ -0,0 +1,17 @@
import gleam/map
import gleam/list
import ibroadcast/request_params.{RequestParams}
/// Combine two request parameter lists into one, later values override former.
pub fn combine_params(params: List(RequestParams)) -> RequestParams {
params
|> list.flatten()
|> list.fold(
map.new(),
fn(acc, item) {
let #(key, val) = item
map.insert(acc, key, val)
},
)
|> map.to_list()
}

3
src/navigator_ffi.mjs Normal file
View file

@ -0,0 +1,3 @@
export function userAgent() {
return globalThis.navigator.userAgent;
}

5
src/player_ffi.mjs Normal file
View file

@ -0,0 +1,5 @@
const player_id = "player-elem";
export function registerEndedCallback(callback) {
document.getElementById(player_id).addEventListener("ended", callback);
}

61
style.css Normal file
View file

@ -0,0 +1,61 @@
:root {
--background-color: #abcdef;
--text-color: #123456;
}
body {
background-color: var(--background-color);
color: var(--text-color);
overflow: hidden;
}
main {
margin: 10px;
overflow: hidden;
}
#authed-view-content {
height: calc(100vh - 2 * 10px);
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
overflow: hidden;
gap: 10px;
}
#player {
}
#search-positioner {
position: relative;
}
#search-bar {
position: absolute;
transform: translateY(-200%);
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
gap: 10px;
}
#search-bar button {
justify-self: flex-end;
}
#search-bar-input-wrapper {
flex: 1 1;
}
#search-bar-input-wrapper input {
width: 100%;
}
.track-list {
flex: 1 1;
overflow-y: auto;
}

View file

@ -0,0 +1,12 @@
import gleeunit
import gleeunit/should
pub fn main() {
gleeunit.main()
}
// gleeunit test functions end in `_test`
pub fn hello_world_test() {
1
|> should.equal(1)
}