Hackfest
This commit is contained in:
commit
20ffb24b30
48 changed files with 2140 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.ez
|
||||||
|
build
|
22
README.md
Normal file
22
README.md
Normal 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
24
gleam.toml
Normal 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
27
index.html
Normal 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
26
manifest.toml
Normal 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
289
normalize.css
vendored
Normal 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
38
src/date_ffi.mjs
Normal 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;
|
||||||
|
}
|
7
src/elekf/api/auth/models.gleam
Normal file
7
src/elekf/api/auth/models.gleam
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
pub type User {
|
||||||
|
User(id: Int, token: String, session_uuid: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Device {
|
||||||
|
Device(name: String)
|
||||||
|
}
|
64
src/elekf/api/auth/storage.gleam
Normal file
64
src/elekf/api/auth/storage.gleam
Normal 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))
|
||||||
|
}
|
14
src/elekf/api/base_request_config.gleam
Normal file
14
src/elekf/api/base_request_config.gleam
Normal 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
39
src/elekf/library.gleam
Normal 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
|
||||||
|
}
|
11
src/elekf/library/album.gleam
Normal file
11
src/elekf/library/album.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
3
src/elekf/library/artist.gleam
Normal file
3
src/elekf/library/artist.gleam
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pub type Artist {
|
||||||
|
Artist(name: String, tracks: List(Int), trashed: Bool, rating: Int)
|
||||||
|
}
|
25
src/elekf/library/track.gleam
Normal file
25
src/elekf/library/track.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
14
src/elekf/transfer/album.gleam
Normal file
14
src/elekf/transfer/album.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
11
src/elekf/transfer/artist.gleam
Normal file
11
src/elekf/transfer/artist.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
23
src/elekf/transfer/library.gleam
Normal file
23
src/elekf/transfer/library.gleam
Normal 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)) },
|
||||||
|
)
|
||||||
|
}
|
29
src/elekf/transfer/track.gleam
Normal file
29
src/elekf/transfer/track.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
51
src/elekf/utils/date.gleam
Normal file
51
src/elekf/utils/date.gleam
Normal 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)
|
||||||
|
}
|
16
src/elekf/utils/http.gleam
Normal file
16
src/elekf/utils/http.gleam
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
2
src/elekf/utils/navigator.gleam
Normal file
2
src/elekf/utils/navigator.gleam
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
@external(javascript, "../../navigator_ffi.mjs", "userAgent")
|
||||||
|
pub fn user_agent() -> String
|
7
src/elekf/utils/option.gleam
Normal file
7
src/elekf/utils/option.gleam
Normal 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
|
||||||
|
}
|
244
src/elekf/web/authed_view.gleam
Normal file
244
src/elekf/web/authed_view.gleam
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
9
src/elekf/web/common.gleam
Normal file
9
src/elekf/web/common.gleam
Normal 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)
|
||||||
|
}
|
172
src/elekf/web/components/player.gleam
Normal file
172
src/elekf/web/components/player.gleam
Normal 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
|
51
src/elekf/web/components/search.gleam
Normal file
51
src/elekf/web/components/search.gleam
Normal 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("🔎")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
128
src/elekf/web/login_view.gleam
Normal file
128
src/elekf/web/login_view.gleam
Normal 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
177
src/elekf/web/main.gleam
Normal 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
17
src/elekf/web/utils.gleam
Normal 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
4
src/elekf/web/view.gleam
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
pub type View {
|
||||||
|
LoginView
|
||||||
|
AuthedView
|
||||||
|
}
|
13
src/elektrofoni.gleam
Normal file
13
src/elektrofoni.gleam
Normal 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
|
3
src/ibroadcast/app_info.gleam
Normal file
3
src/ibroadcast/app_info.gleam
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pub type AppInfo {
|
||||||
|
AppInfo(client: String, version: String)
|
||||||
|
}
|
88
src/ibroadcast/auth/login_token.gleam
Normal file
88
src/ibroadcast/auth/login_token.gleam
Normal 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)
|
||||||
|
}
|
6
src/ibroadcast/auth/logout.gleam
Normal file
6
src/ibroadcast/auth/logout.gleam
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import gleam/json
|
||||||
|
import ibroadcast/request_params.{RequestParams}
|
||||||
|
|
||||||
|
pub fn request_params() -> RequestParams {
|
||||||
|
[#("mode", json.string("logout"))]
|
||||||
|
}
|
35
src/ibroadcast/authed_request.gleam
Normal file
35
src/ibroadcast/authed_request.gleam
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
3
src/ibroadcast/device_info.gleam
Normal file
3
src/ibroadcast/device_info.gleam
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pub type DeviceInfo {
|
||||||
|
DeviceInfo(name: String, user_agent: String)
|
||||||
|
}
|
8
src/ibroadcast/http.gleam
Normal file
8
src/ibroadcast/http.gleam
Normal 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))
|
194
src/ibroadcast/library/library.gleam
Normal file
194
src/ibroadcast/library/library.gleam
Normal 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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
43
src/ibroadcast/map_format.gleam
Normal file
43
src/ibroadcast/map_format.gleam
Normal 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: [],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
45
src/ibroadcast/request.gleam
Normal file
45
src/ibroadcast/request.gleam
Normal 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) })
|
||||||
|
}
|
4
src/ibroadcast/request_params.gleam
Normal file
4
src/ibroadcast/request_params.gleam
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import gleam/json
|
||||||
|
|
||||||
|
pub type RequestParams =
|
||||||
|
List(#(String, json.Json))
|
22
src/ibroadcast/servers.gleam
Normal file
22
src/ibroadcast/servers.gleam
Normal 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,
|
||||||
|
)
|
32
src/ibroadcast/streaming.gleam
Normal file
32
src/ibroadcast/streaming.gleam
Normal 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),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
}
|
17
src/ibroadcast/utils.gleam
Normal file
17
src/ibroadcast/utils.gleam
Normal 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
3
src/navigator_ffi.mjs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function userAgent() {
|
||||||
|
return globalThis.navigator.userAgent;
|
||||||
|
}
|
5
src/player_ffi.mjs
Normal file
5
src/player_ffi.mjs
Normal 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
61
style.css
Normal 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;
|
||||||
|
}
|
12
test/elektrofoni_test.gleam
Normal file
12
test/elektrofoni_test.gleam
Normal 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)
|
||||||
|
}
|
Loading…
Reference in a new issue