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