Implement history API, add initial view stack stuff

This commit is contained in:
Mikko Ahlroth 2024-01-28 17:14:04 +02:00
parent 80f77dbf1e
commit 6f4cea8614
14 changed files with 476 additions and 187 deletions

View file

@ -1,2 +1,2 @@
gleam 0.33.0
gleam 0.34.1
nodejs 20.10.0

View file

@ -20,6 +20,7 @@ gleam_fetch = "~> 0.3"
plinth = "~> 0.1"
varasto = "~> 2.0"
lustre = "~> 3.1"
birl = "~> 1.3"
[dev-dependencies]
gleeunit = "~> 1.0"

View file

@ -2,19 +2,27 @@
# You typically do not need to edit this file
packages = [
{ name = "gleam_fetch", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_javascript", "gleam_http"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "E6B10E4D02F83FFABABDCFC515BE8A8F13F5BB39513E85DDC94B1B3872A20328" },
{ name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" },
{ name = "birl", version = "1.4.0", build_tools = ["gleam"], requirements = ["ranger", "gleam_stdlib"], otp_app = "birl", source = "hex", outer_checksum = "0D643A9FECCAF75E5AF108CD2AFE39B04190B16EDC8284C03D8EF09815B0D832" },
{ name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" },
{ name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" },
{ name = "gleam_fetch", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "24F83C6EF5BC274EF4D712B374D7CDB795136883DA64E2BA6435AF8E6C57E6E2" },
{ name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" },
{ name = "gleam_javascript", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "B5E05F479C52217C02BA2E8FC650A716BFB62D4F8D20A90909C908598E12FBE0" },
{ name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
{ name = "gleam_javascript", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EEA30D1ABF62B06FC378764D598DF041303CFA33A6586BFF4C4BFEFFA83DBDBE" },
{ name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
{ name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" },
{ name = "lustre", version = "3.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "B2A498D83088FB13140645D74AE90FB5A171A715BD12508EB6284EAA8D94EDC8" },
{ name = "plinth", version = "0.1.6", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "EAF2A3229F618C6831B6AE23E9DB7DA793C9D4453CC7C40D6E6FE16F1E2524B5" },
{ name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "snag", "gleam_community_colour", "gleam_community_ansi"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" },
{ name = "lustre", version = "3.1.3", build_tools = ["gleam"], requirements = ["argv", "gleam_community_ansi", "glint", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "873B047E6598FA7041E5685C8C9E030AF753A9CF2EF0F6DBB3A4E6AC92461F0D" },
{ name = "plinth", version = "0.1.9", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_javascript", "gleam_json"], otp_app = "plinth", source = "hex", outer_checksum = "89BE43DC719539A676A9515C619315A2A7188A6FF5D7499F8261241BC0B2F1A9" },
{ name = "ranger", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "28E615AE7590ED922AF1510DDF606A2ECBBC2A9609AF36D412EDC925F06DFD20" },
{ name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" },
{ name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "plinth", "gleam_json"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" },
{ name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["plinth", "gleam_json", "gleam_stdlib"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" },
]
[requirements]
birl = { version = "~> 1.3" }
gleam_fetch = { version = "~> 0.3" }
gleam_http = { version = "~> 3.5" }
gleam_javascript = { version = "~> 0.7" }

View file

@ -0,0 +1,63 @@
//// History events are for changing the play history in the service. These will
//// also scrobble the tracks to Last.FM.
import gleam/int
import gleam/dict
import gleam/list
import gleam/result
import birl
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/history as history_api
import ibroadcast/http.{type Requestor}
import elekf/library/track.{type Track}
/// Type of history event.
pub type HistoryEventType {
/// The track was played.
Play
/// The track was skipped.
Skip
}
/// An event that occurred for a track.
pub type HistoryEvent {
HistoryEvent(
track_id: Int,
track: Track,
type_: HistoryEventType,
when: birl.Time,
)
}
pub type History =
List(HistoryEvent)
/// Send an update to the history API.
pub fn update_history(
history: History,
request_config: RequestConfig,
requestor: Requestor(err_type),
) {
history_api.set_history(to_api_format(history), request_config, requestor)
}
fn to_api_format(history: History) -> history_api.History {
list.fold(history, dict.new(), fn(acc, event) {
let day = birl.get_day(event.when)
let type_constructor = case event.type_ {
Play -> history_api.Play
Skip -> history_api.Skip
}
let history_day =
result.unwrap(
dict.get(acc, day),
history_api.HistoryDay(day: day, plays: dict.new(), detail: dict.new()),
)
|> history_api.add_to_day(
int.to_string(event.track_id),
type_constructor(event.when),
)
dict.insert(acc, day, history_day)
})
|> dict.values()
}

View file

@ -1,5 +1,5 @@
import gleam/list
import gleam/map.{type Map}
import gleam/dict.{type Dict}
import ibroadcast/library/library.{type Library as APILibrary} as api_library
import elekf/library.{Library}
import elekf/transfer/album
@ -14,11 +14,10 @@ pub fn from(library: APILibrary) {
Library(albums: albums, artists: artists, tracks: tracks)
}
fn transfer_map(data: Map(Int, a), transferrer: fn(a) -> b) -> Map(Int, b) {
fn transfer_map(data: Dict(Int, a), transferrer: fn(a) -> b) -> Dict(Int, b) {
data
|> map.to_list()
|> list.fold(
map.new(),
fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) },
)
|> dict.to_list()
|> list.fold(dict.new(), fn(acc, item) {
dict.insert(acc, item.0, transferrer(item.1))
})
}

View file

@ -4,16 +4,20 @@
import gleam/io
import gleam/list
import gleam/option
import gleam/float
import gleam/int
import gleam/javascript/promise
import lustre/element.{text}
import lustre/element/html.{div, nav, p}
import lustre/attribute
import lustre/effect
import lustre/event
import elekf/web/common.{type PlayQueue}
import birl
import ibroadcast/library/library as library_api
import ibroadcast/authed_request.{type RequestConfig, RequestConfig}
import elekf/web/common.{type PlayQueue}
import elekf/api/base_request_config.{base_request_config}
import elekf/api/history
import elekf/utils/http
import elekf/library.{type Library}
import elekf/library/artist.{type Artist}
@ -32,6 +36,15 @@ import elekf/web/events/start_play
import elekf/web/events/show_artist
import elekf/web/utils
import elekf/web/components/icon.{Alt, icon}
import elektrofoni
/// Status of the current track that is being played.
pub type CurrentTrackStatus {
/// We are tracking the play status.
Tracking(played: Float, last_time: Float)
/// History status update has been sent for this track.
HistorySent
}
pub type PlayInfo {
PlayInfo(
@ -40,6 +53,7 @@ pub type PlayInfo {
play_queue: PlayQueue,
play_index: Int,
player: player.Model,
current_track_status: CurrentTrackStatus,
)
}
@ -55,7 +69,7 @@ pub type Model {
settings: option.Option(common.Settings),
request_config: RequestConfig,
play_status: PlayStatus,
library_view: library_view.View,
view_stack: List(library_view.View),
)
}
@ -76,7 +90,7 @@ pub fn init(auth_data: common.AuthData) {
settings: option.None,
request_config: form_request_config(auth_data),
play_status: NoTracks,
library_view: library_view.Tracks,
view_stack: [library_view.Tracks],
)
#(model, load_library(model))
@ -88,7 +102,7 @@ pub fn update(model: Model, msg) {
let new_config = form_request_config(auth_data)
let #(play_status, player_effect) = case model.play_status {
HasTracks(PlayInfo(track_id, track, play_queue, play_index, p)) -> {
HasTracks(PlayInfo(player: p, ..) as play_info) -> {
let #(new_model, e) =
utils.update_child(
p,
@ -96,16 +110,7 @@ pub fn update(model: Model, msg) {
player.update,
PlayerMsg,
)
#(
HasTracks(PlayInfo(
track_id,
track,
play_queue,
play_index,
new_model,
)),
e,
)
#(HasTracks(PlayInfo(..play_info, player: new_model)), e)
}
NoTracks -> #(NoTracks, effect.none())
}
@ -140,47 +145,50 @@ pub fn update(model: Model, msg) {
#(Model(..model, play_status: status), effect)
}
ShowArtist(artist) -> #(
Model(..model, library_view: library_view.SingleArtist(artist)),
Model(
..model,
view_stack: list.append(model.view_stack, [
library_view.SingleArtist(artist),
]),
),
effect.none(),
)
PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> {
if_player(
model,
fn(info) {
let next_index = case msg {
PlayerMsg(player.NextTrack) -> info.play_index + 1
_ -> info.play_index - 1
if_player(model, fn(info) {
let next_index = case msg {
PlayerMsg(player.NextTrack) -> info.play_index + 1
_ -> info.play_index - 1
}
case list.at(info.play_queue, next_index) {
Ok(_) -> {
let #(status, effect) =
handle_start_play(model, info.play_queue, next_index)
#(Model(..model, play_status: status), effect)
}
case list.at(info.play_queue, next_index) {
Ok(_) -> {
let #(status, effect) =
handle_start_play(model, info.play_queue, next_index)
#(Model(..model, play_status: status), effect)
}
Error(_) -> #(model, effect.none())
}
},
)
Error(_) -> #(model, effect.none())
}
})
}
PlayerMsg(msg) -> {
if_player(
model,
fn(info) {
let #(player_model, player_effect) =
utils.update_child(info.player, msg, player.update, PlayerMsg)
if_player(model, fn(info) {
let #(player_model, player_effect) =
utils.update_child(info.player, msg, player.update, PlayerMsg)
#(
Model(
..model,
play_status: HasTracks(PlayInfo(..info, player: player_model)),
),
player_effect,
)
},
)
let play_info =
update_play_info(info, msg)
|> maybe_send_history_status(model.request_config)
#(
Model(
..model,
play_status: HasTracks(PlayInfo(..play_info, player: player_model)),
),
player_effect,
)
})
}
ChangeView(view) -> {
#(Model(..model, library_view: view), effect.none())
#(Model(..model, view_stack: [view]), effect.none())
}
}
}
@ -191,113 +199,78 @@ pub fn view(model: Model) {
NoTracks -> attribute.attribute("data-player-status", "closed")
}
div(
[attribute.id("authed-view-wrapper"), player_open_attribute],
[
div(
[attribute.id("library-loading-indicator")],
[
case model.loading_library {
True -> p([], [text("Loading library…")])
False -> text("")
},
],
),
div(
[attribute.id("authed-view-library")],
[
nav(
[attribute.id("library-top-nav")],
[
button_group.view(
"",
[],
[
button.view(
"",
[event.on_click(ChangeView(library_view.Tracks))],
[icon("music-note-beamed", Alt("Tracks"))],
),
button.view(
"",
[event.on_click(ChangeView(library_view.Artists))],
[icon("file-person", Alt("Artists"))],
),
button.view(
"",
[event.on_click(ChangeView(library_view.Albums))],
[icon("disc", Alt("Albums"))],
),
],
),
],
),
case model.library_view {
library_view.Tracks ->
tracks_view.render(
model.library,
model.settings,
[
attribute.id("tracks-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
],
)
library_view.Artists ->
artists_view.render(
model.library,
model.settings,
[
attribute.id("artists-view"),
attribute.class("glass-bg"),
show_artist.on(ShowArtist),
],
)
library_view.Albums ->
albums_view.render(
model.library,
model.settings,
[
attribute.id("albums-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
],
)
library_view.SingleArtist(artist_info) ->
single_artist_view.render(
model.library,
artist_info,
model.settings,
[
attribute.id("single-artist-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
],
)
},
],
),
div(
[
attribute.id("authed-view-player"),
attribute.class("glass-bg glass-shadow glass-blur glass-border"),
],
[
case model.play_status {
HasTracks(PlayInfo(player: player, ..)) ->
div(
[attribute.id("player")],
[
player.view(player)
|> element.map(PlayerMsg),
],
)
NoTracks -> text("")
},
],
),
],
)
div([attribute.id("authed-view-wrapper"), player_open_attribute], [
div([attribute.id("library-loading-indicator")], [
case model.loading_library {
True -> p([], [text("Loading library…")])
False -> text("")
},
]),
div([attribute.id("authed-view-library")], [
nav([attribute.id("library-top-nav")], [
button_group.view("", [], [
button.view("", [event.on_click(ChangeView(library_view.Tracks))], [
icon("music-note-beamed", Alt("Tracks")),
]),
button.view("", [event.on_click(ChangeView(library_view.Artists))], [
icon("file-person", Alt("Artists")),
]),
button.view("", [event.on_click(ChangeView(library_view.Albums))], [
icon("disc", Alt("Albums")),
]),
]),
]),
..list.map(model.view_stack, fn(view) {
case view {
library_view.Tracks ->
tracks_view.render(model.library, model.settings, [
attribute.id("tracks-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
])
library_view.Artists ->
artists_view.render(model.library, model.settings, [
attribute.id("artists-view"),
attribute.class("glass-bg"),
show_artist.on(ShowArtist),
])
library_view.Albums ->
albums_view.render(model.library, model.settings, [
attribute.id("albums-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
])
library_view.SingleArtist(artist_info) ->
single_artist_view.render(
model.library,
artist_info,
model.settings,
[
attribute.id("single-artist-view"),
attribute.class("glass-bg"),
start_play.on(StartPlay),
],
)
}
})
]),
div(
[
attribute.id("authed-view-player"),
attribute.class("glass-bg glass-shadow glass-blur glass-border"),
],
[
case model.play_status {
HasTracks(PlayInfo(player: player, ..)) ->
div([attribute.id("player")], [
player.view(player)
|> element.map(PlayerMsg),
])
NoTracks -> text("")
},
],
),
])
}
fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
@ -307,7 +280,12 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
HasTracks(PlayInfo(player: p, ..)) -> p
NoTracks -> {
let assert option.Some(settings) = model.settings
player.init(track_id, track, settings, model.request_config, model.library,
player.init(
track_id,
track,
settings,
model.request_config,
model.library,
)
}
}
@ -320,7 +298,17 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
PlayerMsg,
)
#(HasTracks(PlayInfo(track_id, track, queue, position, new_model)), e)
#(
HasTracks(PlayInfo(
track_id,
track,
queue,
position,
new_model,
Tracking(0.0, 0.0),
)),
e,
)
}
fn if_player(
@ -349,3 +337,54 @@ fn form_request_config(auth_data: common.AuthData) {
base_config: base_request_config(auth_data.device.name),
)
}
fn update_play_info(play_info: PlayInfo, msg: player.Msg) -> PlayInfo {
case msg {
player.StartPlay(..) ->
PlayInfo(..play_info, current_track_status: Tracking(0.0, 0.0))
player.UpdateTime(time) -> {
case play_info.current_track_status {
Tracking(played, last_time) -> {
let diff = time -. last_time
// At maximum, only allow one second to be added, to account for the
// user skipping forward
let to_add = float.clamp(diff, min: 0.0, max: 1.0)
let new_played = played +. to_add
PlayInfo(
..play_info,
current_track_status: Tracking(new_played, time),
)
}
HistorySent -> play_info
}
}
_ -> play_info
}
}
fn maybe_send_history_status(play_info: PlayInfo, request_config: RequestConfig) {
case play_info.current_track_status {
Tracking(played, ..) -> {
case played /. int.to_float(play_info.track.length) {
percentage if percentage >=. elektrofoni.play_record_ratio -> {
history.update_history(
[
history.HistoryEvent(
track_id: play_info.track_id,
track: play_info.track,
type_: history.Play,
when: birl.utc_now(),
),
],
request_config,
http.requestor(),
)
PlayInfo(..play_info, current_track_status: HistorySent)
}
_ -> play_info
}
}
HistorySent -> play_info
}
}

View file

@ -18,3 +18,7 @@ pub const bitrate = 256
/// The expiry of the MP3 URLs generated for streaming, from the current moment
/// onwards, in milliseconds.
pub const track_expiry_length = 10_800_000
/// From 0.0 to 1.0, how much of a song has to be played for it to be tracked as
/// a single "play" (which is recorded in Last.FM also).
pub const play_record_ratio = 0.5

View file

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

View file

@ -0,0 +1,141 @@
//// The history argument can be used to make changes to play history. There is
//// no separate endpoint for it in the API and in this implementation the
//// `status` endpoint is used.
////
//// See https://devguide.ibroadcast.com/?p=api#Play-History
import gleam/dict.{type Dict}
import gleam/json
import gleam/int
import gleam/list
import gleam/result
import gleam/dynamic
import gleam/option
import gleam/javascript/promise
import birl
import ibroadcast/request.{DecodeFailed}
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/http.{type Requestor}
import ibroadcast/auth/status
import ibroadcast/request_params
import ibroadcast/servers
import ibroadcast/time
/// One play event.
pub type Event {
/// The track was played at this time.
Play(when: birl.Time)
/// The track was skipped at this time.
Skip(when: birl.Time)
}
pub type Events =
Dict(String, List(Event))
/// Track plays that occurred on a given day. `plays` contains the totals keyed
/// by track ID, `detail` contains the details of played and skipped tracks,
/// again keyed by track ID.
pub type HistoryDay {
HistoryDay(day: birl.Day, plays: Dict(String, Int), detail: Events)
}
pub type History =
List(HistoryDay)
/// Set play history.
pub fn set_history(
history: History,
config: RequestConfig,
requestor: Requestor(err_type),
) {
use resp <- promise.try_await(authed_request.authed_request(
servers.api,
request_params(history),
config,
requestor,
))
promise.resolve(
json.decode(resp.body, payload_decoder())
|> result.map_error(DecodeFailed),
)
}
/// Get the request params for a history request (using the `status` endpoint).
pub fn request_params(history: History) -> request_params.RequestParams {
let history = json.array(history, day_serializer)
[#("history", history), ..status.request_params()]
}
/// Add an event to a given history day.
pub fn add_to_day(day: HistoryDay, track_id: String, event: Event) -> HistoryDay {
let plays = case event {
Play(_) -> {
let old_times = result.unwrap(dict.get(day.plays, track_id), 0)
dict.insert(day.plays, track_id, old_times + 1)
}
Skip(_) -> day.plays
}
let detail =
dict.update(day.detail, track_id, fn(old_detail) {
case old_detail {
option.Some(old_detail) -> list.append(old_detail, [event])
option.None -> [event]
}
})
HistoryDay(..day, plays: plays, detail: detail)
}
fn payload_decoder() {
dynamic.field("result", dynamic.bool)
}
fn day_serializer(day: HistoryDay) -> json.Json {
json.object([
#(
"day",
json.string(
int.to_string(day.day.year)
<> "-"
<> {
int.to_string(day.day.month)
|> time.pad_time_part()
}
<> "-"
<> {
int.to_string(day.day.date)
|> time.pad_time_part()
},
),
),
#(
"plays",
json.object(
list.map(dict.to_list(day.plays), fn(play) {
#(play.0, json.int(play.1))
}),
),
),
#(
"detail",
json.object(
list.map(dict.to_list(day.detail), fn(item) {
#(
item.0,
json.array(item.1, fn(event) {
json.object([
#("ts", json.string(time.format_timestamp(event.when))),
case event {
Play(..) -> #("event", json.string("play"))
Skip(..) -> #("event", json.string("skip"))
},
])
}),
)
}),
),
),
])
}

View file

@ -1,6 +1,6 @@
import gleam/javascript/promise
import gleam/json
import gleam/map.{type Map}
import gleam/dict.{type Dict}
import gleam/dynamic
import gleam/result
import gleam/option
@ -60,9 +60,9 @@ pub type Track {
pub type Library {
Library(
albums: Map(Int, Album),
artists: Map(Int, Artist),
tracks: Map(Int, Track),
albums: Dict(Int, Album),
artists: Dict(Int, Artist),
tracks: Dict(Int, Track),
)
}

View file

@ -1,5 +1,5 @@
import gleam/result
import gleam/map.{type Map}
import gleam/dict.{type Dict}
import gleam/int
import gleam/dynamic.{type Decoder}
@ -8,19 +8,19 @@ import gleam/dynamic.{type Decoder}
pub fn decoder(
key_decoder: Decoder(k),
val_decoder: Decoder(v),
) -> Decoder(Map(k, v)) {
) -> Decoder(Dict(k, v)) {
fn(data) {
// First decode into a `Map(Dynamic, Dynamic)`
use dynamic_map <- result.map(dynamic.map(dynamic.dynamic, dynamic.dynamic)(
use dynamic_map <- result.map(dynamic.dict(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())
use map, dyn_key, dyn_val <- dict.fold(dynamic_map, dict.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)
Ok(key), Ok(val) -> dict.insert(map, key, val)
// Otherwise just ignore it and carry on
_, _ -> map
}

26
src/ibroadcast/time.gleam Normal file
View file

@ -0,0 +1,26 @@
import gleam/int
import gleam/string
import birl
/// Format a timestamp according to the needs of the API.
/// The format is `2024-01-26 23:37:15` with no offset.
pub fn format_timestamp(time: birl.Time) -> String {
let date_str = birl.to_naive_date_string(time)
let time_of_day = birl.get_time_of_day(time)
let hours_str =
int.to_string(time_of_day.hour)
|> pad_time_part()
let mins_str =
int.to_string(time_of_day.minute)
|> pad_time_part()
let secs_str =
int.to_string(time_of_day.second)
|> pad_time_part()
date_str <> " " <> hours_str <> ":" <> mins_str <> ":" <> secs_str
}
/// Pad a time part (months, days, hours, minutes, seconds) to two characters.
pub fn pad_time_part(str: String) -> String {
string.pad_left(str, to: 2, with: "0")
}

View file

@ -1,4 +1,4 @@
import gleam/map
import gleam/dict
import gleam/list
import ibroadcast/request_params.{type RequestParams}
@ -6,12 +6,9 @@ import ibroadcast/request_params.{type RequestParams}
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()
|> list.fold(dict.new(), fn(acc, item) {
let #(key, val) = item
dict.insert(acc, key, val)
})
|> dict.to_list()
}

View file

@ -153,6 +153,11 @@ single-artist-view {
overflow-y: auto;
}
/* Ensure that the view stack only shows the topmost view and the nav bar. */
#authed-view-library > *:not(:first-child, :last-child) {
display: none;
}
#authed-view-player {
position: absolute;
bottom: 0;