Implement history API, add initial view stack stuff
This commit is contained in:
parent
80f77dbf1e
commit
6f4cea8614
14 changed files with 476 additions and 187 deletions
|
@ -1,2 +1,2 @@
|
||||||
gleam 0.33.0
|
gleam 0.34.1
|
||||||
nodejs 20.10.0
|
nodejs 20.10.0
|
||||||
|
|
|
@ -20,6 +20,7 @@ gleam_fetch = "~> 0.3"
|
||||||
plinth = "~> 0.1"
|
plinth = "~> 0.1"
|
||||||
varasto = "~> 2.0"
|
varasto = "~> 2.0"
|
||||||
lustre = "~> 3.1"
|
lustre = "~> 3.1"
|
||||||
|
birl = "~> 1.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gleeunit = "~> 1.0"
|
gleeunit = "~> 1.0"
|
||||||
|
|
|
@ -2,19 +2,27 @@
|
||||||
# You typically do not need to edit this file
|
# You typically do not need to edit this file
|
||||||
|
|
||||||
packages = [
|
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_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_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 = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
|
{ 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 = "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 = "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 = "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 = "plinth", version = "0.1.6", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "EAF2A3229F618C6831B6AE23E9DB7DA793C9D4453CC7C40D6E6FE16F1E2524B5" },
|
{ 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 = "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]
|
[requirements]
|
||||||
|
birl = { version = "~> 1.3" }
|
||||||
gleam_fetch = { version = "~> 0.3" }
|
gleam_fetch = { version = "~> 0.3" }
|
||||||
gleam_http = { version = "~> 3.5" }
|
gleam_http = { version = "~> 3.5" }
|
||||||
gleam_javascript = { version = "~> 0.7" }
|
gleam_javascript = { version = "~> 0.7" }
|
||||||
|
|
63
src/elekf/api/history.gleam
Normal file
63
src/elekf/api/history.gleam
Normal 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()
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import gleam/list
|
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 ibroadcast/library/library.{type Library as APILibrary} as api_library
|
||||||
import elekf/library.{Library}
|
import elekf/library.{Library}
|
||||||
import elekf/transfer/album
|
import elekf/transfer/album
|
||||||
|
@ -14,11 +14,10 @@ pub fn from(library: APILibrary) {
|
||||||
Library(albums: albums, artists: artists, tracks: tracks)
|
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
|
data
|
||||||
|> map.to_list()
|
|> dict.to_list()
|
||||||
|> list.fold(
|
|> list.fold(dict.new(), fn(acc, item) {
|
||||||
map.new(),
|
dict.insert(acc, item.0, transferrer(item.1))
|
||||||
fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) },
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,16 +4,20 @@
|
||||||
import gleam/io
|
import gleam/io
|
||||||
import gleam/list
|
import gleam/list
|
||||||
import gleam/option
|
import gleam/option
|
||||||
|
import gleam/float
|
||||||
|
import gleam/int
|
||||||
import gleam/javascript/promise
|
import gleam/javascript/promise
|
||||||
import lustre/element.{text}
|
import lustre/element.{text}
|
||||||
import lustre/element/html.{div, nav, p}
|
import lustre/element/html.{div, nav, p}
|
||||||
import lustre/attribute
|
import lustre/attribute
|
||||||
import lustre/effect
|
import lustre/effect
|
||||||
import lustre/event
|
import lustre/event
|
||||||
import elekf/web/common.{type PlayQueue}
|
import birl
|
||||||
import ibroadcast/library/library as library_api
|
import ibroadcast/library/library as library_api
|
||||||
import ibroadcast/authed_request.{type RequestConfig, RequestConfig}
|
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/base_request_config.{base_request_config}
|
||||||
|
import elekf/api/history
|
||||||
import elekf/utils/http
|
import elekf/utils/http
|
||||||
import elekf/library.{type Library}
|
import elekf/library.{type Library}
|
||||||
import elekf/library/artist.{type Artist}
|
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/events/show_artist
|
||||||
import elekf/web/utils
|
import elekf/web/utils
|
||||||
import elekf/web/components/icon.{Alt, icon}
|
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 {
|
pub type PlayInfo {
|
||||||
PlayInfo(
|
PlayInfo(
|
||||||
|
@ -40,6 +53,7 @@ pub type PlayInfo {
|
||||||
play_queue: PlayQueue,
|
play_queue: PlayQueue,
|
||||||
play_index: Int,
|
play_index: Int,
|
||||||
player: player.Model,
|
player: player.Model,
|
||||||
|
current_track_status: CurrentTrackStatus,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +69,7 @@ pub type Model {
|
||||||
settings: option.Option(common.Settings),
|
settings: option.Option(common.Settings),
|
||||||
request_config: RequestConfig,
|
request_config: RequestConfig,
|
||||||
play_status: PlayStatus,
|
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,
|
settings: option.None,
|
||||||
request_config: form_request_config(auth_data),
|
request_config: form_request_config(auth_data),
|
||||||
play_status: NoTracks,
|
play_status: NoTracks,
|
||||||
library_view: library_view.Tracks,
|
view_stack: [library_view.Tracks],
|
||||||
)
|
)
|
||||||
|
|
||||||
#(model, load_library(model))
|
#(model, load_library(model))
|
||||||
|
@ -88,7 +102,7 @@ pub fn update(model: Model, msg) {
|
||||||
let new_config = form_request_config(auth_data)
|
let new_config = form_request_config(auth_data)
|
||||||
|
|
||||||
let #(play_status, player_effect) = case model.play_status {
|
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) =
|
let #(new_model, e) =
|
||||||
utils.update_child(
|
utils.update_child(
|
||||||
p,
|
p,
|
||||||
|
@ -96,16 +110,7 @@ pub fn update(model: Model, msg) {
|
||||||
player.update,
|
player.update,
|
||||||
PlayerMsg,
|
PlayerMsg,
|
||||||
)
|
)
|
||||||
#(
|
#(HasTracks(PlayInfo(..play_info, player: new_model)), e)
|
||||||
HasTracks(PlayInfo(
|
|
||||||
track_id,
|
|
||||||
track,
|
|
||||||
play_queue,
|
|
||||||
play_index,
|
|
||||||
new_model,
|
|
||||||
)),
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
NoTracks -> #(NoTracks, effect.none())
|
NoTracks -> #(NoTracks, effect.none())
|
||||||
}
|
}
|
||||||
|
@ -140,47 +145,50 @@ pub fn update(model: Model, msg) {
|
||||||
#(Model(..model, play_status: status), effect)
|
#(Model(..model, play_status: status), effect)
|
||||||
}
|
}
|
||||||
ShowArtist(artist) -> #(
|
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(),
|
effect.none(),
|
||||||
)
|
)
|
||||||
PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> {
|
PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> {
|
||||||
if_player(
|
if_player(model, fn(info) {
|
||||||
model,
|
let next_index = case msg {
|
||||||
fn(info) {
|
PlayerMsg(player.NextTrack) -> info.play_index + 1
|
||||||
let next_index = case msg {
|
_ -> info.play_index - 1
|
||||||
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) {
|
Error(_) -> #(model, effect.none())
|
||||||
Ok(_) -> {
|
}
|
||||||
let #(status, effect) =
|
})
|
||||||
handle_start_play(model, info.play_queue, next_index)
|
|
||||||
#(Model(..model, play_status: status), effect)
|
|
||||||
}
|
|
||||||
Error(_) -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PlayerMsg(msg) -> {
|
PlayerMsg(msg) -> {
|
||||||
if_player(
|
if_player(model, fn(info) {
|
||||||
model,
|
let #(player_model, player_effect) =
|
||||||
fn(info) {
|
utils.update_child(info.player, msg, player.update, PlayerMsg)
|
||||||
let #(player_model, player_effect) =
|
|
||||||
utils.update_child(info.player, msg, player.update, PlayerMsg)
|
|
||||||
|
|
||||||
#(
|
let play_info =
|
||||||
Model(
|
update_play_info(info, msg)
|
||||||
..model,
|
|> maybe_send_history_status(model.request_config)
|
||||||
play_status: HasTracks(PlayInfo(..info, player: player_model)),
|
|
||||||
),
|
#(
|
||||||
player_effect,
|
Model(
|
||||||
)
|
..model,
|
||||||
},
|
play_status: HasTracks(PlayInfo(..play_info, player: player_model)),
|
||||||
)
|
),
|
||||||
|
player_effect,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
ChangeView(view) -> {
|
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")
|
NoTracks -> attribute.attribute("data-player-status", "closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
div(
|
div([attribute.id("authed-view-wrapper"), player_open_attribute], [
|
||||||
[attribute.id("authed-view-wrapper"), player_open_attribute],
|
div([attribute.id("library-loading-indicator")], [
|
||||||
[
|
case model.loading_library {
|
||||||
div(
|
True -> p([], [text("Loading library…")])
|
||||||
[attribute.id("library-loading-indicator")],
|
False -> text("")
|
||||||
[
|
},
|
||||||
case model.loading_library {
|
]),
|
||||||
True -> p([], [text("Loading library…")])
|
div([attribute.id("authed-view-library")], [
|
||||||
False -> text("")
|
nav([attribute.id("library-top-nav")], [
|
||||||
},
|
button_group.view("", [], [
|
||||||
],
|
button.view("", [event.on_click(ChangeView(library_view.Tracks))], [
|
||||||
),
|
icon("music-note-beamed", Alt("Tracks")),
|
||||||
div(
|
]),
|
||||||
[attribute.id("authed-view-library")],
|
button.view("", [event.on_click(ChangeView(library_view.Artists))], [
|
||||||
[
|
icon("file-person", Alt("Artists")),
|
||||||
nav(
|
]),
|
||||||
[attribute.id("library-top-nav")],
|
button.view("", [event.on_click(ChangeView(library_view.Albums))], [
|
||||||
[
|
icon("disc", Alt("Albums")),
|
||||||
button_group.view(
|
]),
|
||||||
"",
|
]),
|
||||||
[],
|
]),
|
||||||
[
|
..list.map(model.view_stack, fn(view) {
|
||||||
button.view(
|
case view {
|
||||||
"",
|
library_view.Tracks ->
|
||||||
[event.on_click(ChangeView(library_view.Tracks))],
|
tracks_view.render(model.library, model.settings, [
|
||||||
[icon("music-note-beamed", Alt("Tracks"))],
|
attribute.id("tracks-view"),
|
||||||
),
|
attribute.class("glass-bg"),
|
||||||
button.view(
|
start_play.on(StartPlay),
|
||||||
"",
|
])
|
||||||
[event.on_click(ChangeView(library_view.Artists))],
|
library_view.Artists ->
|
||||||
[icon("file-person", Alt("Artists"))],
|
artists_view.render(model.library, model.settings, [
|
||||||
),
|
attribute.id("artists-view"),
|
||||||
button.view(
|
attribute.class("glass-bg"),
|
||||||
"",
|
show_artist.on(ShowArtist),
|
||||||
[event.on_click(ChangeView(library_view.Albums))],
|
])
|
||||||
[icon("disc", Alt("Albums"))],
|
library_view.Albums ->
|
||||||
),
|
albums_view.render(model.library, model.settings, [
|
||||||
],
|
attribute.id("albums-view"),
|
||||||
),
|
attribute.class("glass-bg"),
|
||||||
],
|
start_play.on(StartPlay),
|
||||||
),
|
])
|
||||||
case model.library_view {
|
library_view.SingleArtist(artist_info) ->
|
||||||
library_view.Tracks ->
|
single_artist_view.render(
|
||||||
tracks_view.render(
|
model.library,
|
||||||
model.library,
|
artist_info,
|
||||||
model.settings,
|
model.settings,
|
||||||
[
|
[
|
||||||
attribute.id("tracks-view"),
|
attribute.id("single-artist-view"),
|
||||||
attribute.class("glass-bg"),
|
attribute.class("glass-bg"),
|
||||||
start_play.on(StartPlay),
|
start_play.on(StartPlay),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
library_view.Artists ->
|
}
|
||||||
artists_view.render(
|
})
|
||||||
model.library,
|
]),
|
||||||
model.settings,
|
div(
|
||||||
[
|
[
|
||||||
attribute.id("artists-view"),
|
attribute.id("authed-view-player"),
|
||||||
attribute.class("glass-bg"),
|
attribute.class("glass-bg glass-shadow glass-blur glass-border"),
|
||||||
show_artist.on(ShowArtist),
|
],
|
||||||
],
|
[
|
||||||
)
|
case model.play_status {
|
||||||
library_view.Albums ->
|
HasTracks(PlayInfo(player: player, ..)) ->
|
||||||
albums_view.render(
|
div([attribute.id("player")], [
|
||||||
model.library,
|
player.view(player)
|
||||||
model.settings,
|
|> element.map(PlayerMsg),
|
||||||
[
|
])
|
||||||
attribute.id("albums-view"),
|
NoTracks -> text("")
|
||||||
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) {
|
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
|
HasTracks(PlayInfo(player: p, ..)) -> p
|
||||||
NoTracks -> {
|
NoTracks -> {
|
||||||
let assert option.Some(settings) = model.settings
|
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,
|
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(
|
fn if_player(
|
||||||
|
@ -349,3 +337,54 @@ fn form_request_config(auth_data: common.AuthData) {
|
||||||
base_config: base_request_config(auth_data.device.name),
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -18,3 +18,7 @@ pub const bitrate = 256
|
||||||
/// The expiry of the MP3 URLs generated for streaming, from the current moment
|
/// The expiry of the MP3 URLs generated for streaming, from the current moment
|
||||||
/// onwards, in milliseconds.
|
/// onwards, in milliseconds.
|
||||||
pub const track_expiry_length = 10_800_000
|
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
|
||||||
|
|
6
src/ibroadcast/auth/status.gleam
Normal file
6
src/ibroadcast/auth/status.gleam
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import gleam/json
|
||||||
|
import ibroadcast/request_params.{type RequestParams}
|
||||||
|
|
||||||
|
pub fn request_params() -> RequestParams {
|
||||||
|
[#("mode", json.string("status"))]
|
||||||
|
}
|
141
src/ibroadcast/history.gleam
Normal file
141
src/ibroadcast/history.gleam
Normal 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"))
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import gleam/javascript/promise
|
import gleam/javascript/promise
|
||||||
import gleam/json
|
import gleam/json
|
||||||
import gleam/map.{type Map}
|
import gleam/dict.{type Dict}
|
||||||
import gleam/dynamic
|
import gleam/dynamic
|
||||||
import gleam/result
|
import gleam/result
|
||||||
import gleam/option
|
import gleam/option
|
||||||
|
@ -60,9 +60,9 @@ pub type Track {
|
||||||
|
|
||||||
pub type Library {
|
pub type Library {
|
||||||
Library(
|
Library(
|
||||||
albums: Map(Int, Album),
|
albums: Dict(Int, Album),
|
||||||
artists: Map(Int, Artist),
|
artists: Dict(Int, Artist),
|
||||||
tracks: Map(Int, Track),
|
tracks: Dict(Int, Track),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import gleam/result
|
import gleam/result
|
||||||
import gleam/map.{type Map}
|
import gleam/dict.{type Dict}
|
||||||
import gleam/int
|
import gleam/int
|
||||||
import gleam/dynamic.{type Decoder}
|
import gleam/dynamic.{type Decoder}
|
||||||
|
|
||||||
|
@ -8,19 +8,19 @@ import gleam/dynamic.{type Decoder}
|
||||||
pub fn decoder(
|
pub fn decoder(
|
||||||
key_decoder: Decoder(k),
|
key_decoder: Decoder(k),
|
||||||
val_decoder: Decoder(v),
|
val_decoder: Decoder(v),
|
||||||
) -> Decoder(Map(k, v)) {
|
) -> Decoder(Dict(k, v)) {
|
||||||
fn(data) {
|
fn(data) {
|
||||||
// First decode into a `Map(Dynamic, Dynamic)`
|
// 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,
|
data,
|
||||||
))
|
))
|
||||||
// Fold over that dynamic map. The accumulator will be the desired `Map(k, v)`
|
// 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
|
// Attempt to decode the current value
|
||||||
case key_decoder(dyn_key), val_decoder(dyn_val) {
|
case key_decoder(dyn_key), val_decoder(dyn_val) {
|
||||||
// If it succeeds insert the new entry
|
// 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
|
// Otherwise just ignore it and carry on
|
||||||
_, _ -> map
|
_, _ -> map
|
||||||
}
|
}
|
||||||
|
|
26
src/ibroadcast/time.gleam
Normal file
26
src/ibroadcast/time.gleam
Normal 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")
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import gleam/map
|
import gleam/dict
|
||||||
import gleam/list
|
import gleam/list
|
||||||
import ibroadcast/request_params.{type RequestParams}
|
import ibroadcast/request_params.{type RequestParams}
|
||||||
|
|
||||||
|
@ -6,12 +6,9 @@ import ibroadcast/request_params.{type RequestParams}
|
||||||
pub fn combine_params(params: List(RequestParams)) -> RequestParams {
|
pub fn combine_params(params: List(RequestParams)) -> RequestParams {
|
||||||
params
|
params
|
||||||
|> list.flatten()
|
|> list.flatten()
|
||||||
|> list.fold(
|
|> list.fold(dict.new(), fn(acc, item) {
|
||||||
map.new(),
|
let #(key, val) = item
|
||||||
fn(acc, item) {
|
dict.insert(acc, key, val)
|
||||||
let #(key, val) = item
|
})
|
||||||
map.insert(acc, key, val)
|
|> dict.to_list()
|
||||||
},
|
|
||||||
)
|
|
||||||
|> map.to_list()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -153,6 +153,11 @@ single-artist-view {
|
||||||
overflow-y: auto;
|
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 {
|
#authed-view-player {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
Loading…
Reference in a new issue