Initial work for full screen player

This commit is contained in:
Mikko Ahlroth 2024-02-27 15:54:54 +02:00
parent 773627bd5d
commit 9f1af761e5
10 changed files with 313 additions and 204 deletions

View file

@ -25,6 +25,8 @@ import elekf/library/track.{type Track}
import elekf/transfer/library as library_transfer import elekf/transfer/library as library_transfer
import elekf/web/router import elekf/web/router
import elekf/web/components/player import elekf/web/components/player
import elekf/web/components/player/model as player_model
import elekf/web/components/player/actions as player_actions
import elekf/web/components/library_view import elekf/web/components/library_view
import elekf/web/components/library_views/tracks_view import elekf/web/components/library_views/tracks_view
import elekf/web/components/library_views/artists_view import elekf/web/components/library_views/artists_view
@ -52,7 +54,7 @@ pub type PlayInfo {
track: Track, track: Track,
play_queue: PlayQueue, play_queue: PlayQueue,
play_index: Int, play_index: Int,
player: player.Model, player: player_model.Model,
current_track_status: CurrentTrackStatus, current_track_status: CurrentTrackStatus,
) )
} }
@ -156,10 +158,12 @@ pub fn update(model: Model, msg) {
let #(status, effect) = handle_start_play(model, tracks, position) let #(status, effect) = handle_start_play(model, tracks, position)
#(Model(..model, play_status: status), effect) #(Model(..model, play_status: status), effect)
} }
PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> { PlayerMsg(player.ActionTriggered(player_actions.NextTrack))
| PlayerMsg(player.ActionTriggered(player_actions.PrevTrack)) -> {
if_player(model, fn(info) { if_player(model, fn(info) {
let next_index = case msg { let next_index = case msg {
PlayerMsg(player.NextTrack) -> info.play_index + 1 PlayerMsg(player.ActionTriggered(player_actions.NextTrack)) ->
info.play_index + 1
_ -> info.play_index - 1 _ -> info.play_index - 1
} }
case list.at(info.play_queue, next_index) { case list.at(info.play_queue, next_index) {

View file

@ -21,6 +21,7 @@ pub fn view(
item: LibraryItem(Album), item: LibraryItem(Album),
) { ) {
let #(album_id, album) = item let #(album_id, album) = item
let album_id_str = int.to_string(album_id)
let tracks = album_utils.get_tracks(library, album) let tracks = album_utils.get_tracks(library, album)
let artist_name = case album.artist_id { let artist_name = case album.artist_id {
0 -> "Unknown artist" 0 -> "Unknown artist"
@ -32,10 +33,11 @@ pub fn view(
link.link( link.link(
router.to_hash(router.Album(album_id)), router.to_hash(router.Album(album_id)),
"library-item album-item", "library-item album-item",
[attribute.id("album-list-" <> int.to_string(album_id))], [attribute.id("album-list-" <> album_id_str)],
[ [
thumbnail.maybe_item_thumbnail( thumbnail.maybe_item_thumbnail(
settings, settings,
"album-item-thumbnail-" <> album_id_str,
{ first_track.1 }.artwork_id, { first_track.1 }.artwork_id,
album.name, album.name,
), ),

View file

@ -69,14 +69,16 @@ fn item_view(
item: LibraryItem(Artist), item: LibraryItem(Artist),
) { ) {
let #(artist_id, artist) = item let #(artist_id, artist) = item
let artist_id_str = int.to_string(artist_id)
[ [
link.link( link.link(
router.to_hash(router.Artist(artist_id)), router.to_hash(router.Artist(artist_id)),
"library-item artist-item", "library-item artist-item",
[attribute.id("artist-list-" <> int.to_string(artist_id))], [attribute.id("artist-list-" <> artist_id_str)],
[ [
thumbnail.maybe_item_thumbnail( thumbnail.maybe_item_thumbnail(
model.settings, model.settings,
"artist-list-thumbnail-" <> artist_id_str,
artist.artwork_id, artist.artwork_id,
artist.name, artist.name,
), ),

View file

@ -5,8 +5,8 @@ import gleam/int
import gleam/float import gleam/float
import gleam/option import gleam/option
import gleam/io import gleam/io
import lustre/element.{text} import lustre/element
import lustre/element/html.{audio, div, input, p} import lustre/element/html.{audio, div}
import lustre/attribute import lustre/attribute
import lustre/event import lustre/event
import lustre/effect import lustre/effect
@ -14,74 +14,38 @@ import plinth/browser/media/metadata
import plinth/browser/media/session import plinth/browser/media/session
import plinth/browser/media/action import plinth/browser/media/action
import plinth/browser/media/position import plinth/browser/media/position
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/streaming
import ibroadcast/artwork
import elektrofoni import elektrofoni
import elekf/utils/lustre import elekf/utils/lustre
import elekf/web/common import elekf/web/common
import elekf/web/components/icon.{Alt, icon}
import elekf/web/components/track_length.{track_length}
import elekf/web/components/button_group
import elekf/web/components/button
import elekf/web/components/thumbnail
import elekf/web/volume import elekf/web/volume
import elekf/web/components/player/small
import elekf/web/components/player/model.{
type AudioContext, type GainNode, type MediaElementAudioSourceNode, type Model,
type PlayState, Model, Paused, Playing,
}
import elekf/web/components/player/actions
import elekf/library.{type Library} import elekf/library.{type Library}
import elekf/library/track.{type Track} import elekf/library/track.{type Track}
import elekf/library/artist.{type Artist} import elekf/library/artist.{type Artist}
import elekf/library/album.{type Album} import elekf/library/album.{type Album}
import elekf/utils/date import elekf/utils/date
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/streaming
import ibroadcast/artwork
const default_seek_offset = 10.0 const default_seek_offset = 10.0
pub type AudioContext
pub type MediaElementAudioSourceNode
pub type GainNode
pub type PlayState {
Playing
Paused
}
pub type Model {
Model(
settings: common.Settings,
library: Library,
track_id: Int,
track: Track,
artist: Artist,
album: Album,
url: uri.Uri,
position: Int,
state: PlayState,
loading_stream: Bool,
request_config: RequestConfig,
user_is_skipping: Bool,
audio_context: AudioContext,
audio_source: option.Option(MediaElementAudioSourceNode),
gain_node: GainNode,
)
}
pub type Msg { pub type Msg {
StartPlay(Int, Track) StartPlay(Int, Track)
NextTrack PlayStateUpdated(PlayState)
PrevTrack
Play
Pause
SetPlayState(PlayState)
Clear
UpdateTime(Float) UpdateTime(Float)
UpdateRequestConfig(RequestConfig) UpdateRequestConfig(RequestConfig)
StartUserSkip BackwardSeekRequested(Float)
EndUserSkip ForwardSeekRequested(Float)
PositionSelected(Int)
PositionChanged(Int)
SeekBackward(Float)
SeekForward(Float)
CreateMediaElementAudioSourceNode CreateMediaElementAudioSourceNode
FullScreenToggled
ActionTriggered(actions.Action)
PositionSelected(Int)
} }
pub fn init( pub fn init(
@ -96,17 +60,33 @@ pub fn init(
let action_effect = let action_effect =
effect.from(fn(dispatch) { effect.from(fn(dispatch) {
register_action_handler(action.NextTrack, NextTrack, dispatch) register_action_handler(
register_action_handler(action.PreviousTrack, PrevTrack, dispatch) action.NextTrack,
register_action_handler(action.Play, Play, dispatch) ActionTriggered(actions.NextTrack),
register_action_handler(action.Pause, Pause, dispatch) dispatch,
)
register_action_handler(
action.PreviousTrack,
ActionTriggered(actions.PrevTrack),
dispatch,
)
register_action_handler(
action.Play,
ActionTriggered(actions.Play),
dispatch,
)
register_action_handler(
action.Pause,
ActionTriggered(actions.Pause),
dispatch,
)
session.set_action_handler(action.SeekBackward, fn(data) { session.set_action_handler(action.SeekBackward, fn(data) {
let seek_amount = option.unwrap(data.seek_offset, default_seek_offset) let seek_amount = option.unwrap(data.seek_offset, default_seek_offset)
dispatch(SeekBackward(seek_amount)) dispatch(BackwardSeekRequested(seek_amount))
}) })
session.set_action_handler(action.SeekForward, fn(data) { session.set_action_handler(action.SeekForward, fn(data) {
let seek_amount = option.unwrap(data.seek_offset, default_seek_offset) let seek_amount = option.unwrap(data.seek_offset, default_seek_offset)
dispatch(SeekForward(seek_amount)) dispatch(ForwardSeekRequested(seek_amount))
}) })
session.set_action_handler(action.SeekTo, fn(data) { session.set_action_handler(action.SeekTo, fn(data) {
let seek_to = option.unwrap(data.seek_time, 0.0) let seek_to = option.unwrap(data.seek_time, 0.0)
@ -138,6 +118,7 @@ pub fn init(
audio_context, audio_context,
option.None, option.None,
create_gain(audio_context), create_gain(audio_context),
model.Small,
), ),
action_effect, action_effect,
) )
@ -145,6 +126,39 @@ pub fn init(
pub fn update(model: Model, msg) { pub fn update(model: Model, msg) {
case msg { case msg {
ActionTriggered(action) -> {
case action {
actions.Play -> {
play()
#(model, effect.none())
}
actions.Pause -> {
pause()
#(model, effect.none())
}
actions.NextTrack -> #(model, effect.none())
actions.PrevTrack -> #(model, effect.none())
actions.Clear -> #(model, effect.none())
actions.StartUserSkip -> #(
Model(..model, user_is_skipping: True),
effect.none(),
)
actions.EndUserSkip -> #(
Model(..model, user_is_skipping: False),
effect.none(),
)
actions.SelectPosition(actions.Commit) -> {
let pos = current_track_position()
skip_to_time(pos)
#(Model(..model, position: pos), effect.none())
}
actions.SelectPosition(actions.Ephemeral) -> {
let pos = current_track_position()
#(Model(..model, position: pos), effect.none())
}
}
}
StartPlay(track_id, track) -> { StartPlay(track_id, track) -> {
let artist = library.assert_artist(model.library, track.artist_id) let artist = library.assert_artist(model.library, track.artist_id)
let album = library.assert_album(model.library, track.album_id) let album = library.assert_album(model.library, track.album_id)
@ -170,21 +184,10 @@ pub fn update(model: Model, msg) {
effect.none(), effect.none(),
) )
} }
Play -> { PlayStateUpdated(play_state) -> #(
play()
#(model, effect.none())
}
Pause -> {
pause()
#(model, effect.none())
}
SetPlayState(play_state) -> #(
Model(..model, state: play_state), Model(..model, state: play_state),
effect.none(), effect.none(),
) )
NextTrack -> #(model, effect.none())
PrevTrack -> #(model, effect.none())
Clear -> #(model, effect.none())
UpdateTime(time) -> UpdateTime(time) ->
case model.user_is_skipping { case model.user_is_skipping {
True -> #(model, effect.none()) True -> #(model, effect.none())
@ -214,123 +217,63 @@ pub fn update(model: Model, msg) {
Model(..model, request_config: config), Model(..model, request_config: config),
effect.none(), effect.none(),
) )
StartUserSkip -> #(Model(..model, user_is_skipping: True), effect.none()) BackwardSeekRequested(amount) -> {
EndUserSkip -> #(Model(..model, user_is_skipping: False), effect.none()) let new_pos = float.round(current_time() -. amount)
skip_to_time(new_pos)
#(Model(..model, position: new_pos), effect.none())
}
ForwardSeekRequested(amount) -> {
let new_pos = float.round(current_time() +. amount)
skip_to_time(new_pos)
#(Model(..model, position: new_pos), effect.none())
}
PositionSelected(pos) -> { PositionSelected(pos) -> {
skip_to_time(pos) skip_to_time(pos)
#(Model(..model, position: pos), effect.none()) #(Model(..model, position: pos), effect.none())
} }
PositionChanged(pos) -> {
#(Model(..model, position: pos), effect.none())
}
SeekBackward(amount) -> {
let new_pos = float.round(current_time() -. amount)
// TODO: Stop lying to hayleigh
update(model, PositionSelected(new_pos))
}
SeekForward(amount) -> {
let new_pos = float.round(current_time() +. amount)
update(model, PositionSelected(new_pos))
}
CreateMediaElementAudioSourceNode -> { CreateMediaElementAudioSourceNode -> {
let source = create_media_element_source(model.audio_context) let source = create_media_element_source(model.audio_context)
connect_gain(model.gain_node, model.audio_context, source) connect_gain(model.gain_node, model.audio_context, source)
#(Model(..model, audio_source: option.Some(source)), effect.none()) #(Model(..model, audio_source: option.Some(source)), effect.none())
} }
FullScreenToggled -> {
let new_mode = case model.view_mode {
model.Small -> model.FullScreen
model.FullScreen -> model.Small
}
#(Model(..model, view_mode: new_mode), effect.none())
}
} }
} }
pub fn view(model: Model) { pub fn view(model: Model) {
let is_playing = model.state == Playing
let src = uri.to_string(model.url) let src = uri.to_string(model.url)
let current_time_padding = case model.track.length > 3600 {
True -> track_length.Hours
False -> track_length.Auto
}
div([attribute.id("player-wrapper")], [ div([attribute.id("player-wrapper")], [
thumbnail.maybe_view( audio(
option.Some(model.settings), [
"player-wrapper-thumbnail", attribute.id("player-elem"),
"player-wrapper-thumbnail player-wrapper-thumbnail-placeholder", attribute.src(src),
model.track.artwork_id, attribute.controls(False),
model.album.name, attribute.autoplay(True),
attribute.attribute("crossorigin", "anonymous"),
event.on("play", event_play),
event.on("pause", event_pause),
event.on("ended", event_ended),
event.on("timeupdate", event_timeupdate),
],
[],
), ),
p([attribute.id("player-wrapper-track-name")], [text(model.track.title)]), case model.view_mode {
p([attribute.id("player-wrapper-artist-name")], [text(model.artist.name)]), model.Small ->
p([attribute.id("player-wrapper-album-name")], [text(model.album.name)]), small.view(model)
div([attribute.id("player-controls")], [ |> element.map(ActionTriggered)
button_group.view("", [attribute.id("player-controls-buttons")], [ },
button.view(
"button-small",
[attribute.id("player-previous"), event.on_click(PrevTrack)],
[icon("skip-backward-fill", Alt("Previous"))],
),
case is_playing {
True ->
button.view(
"button-small",
[attribute.id("player-play-pause"), event.on_click(Pause)],
[icon("pause-fill", Alt("Pause"))],
)
False ->
button.view(
"button-small",
[attribute.id("player-play-pause"), event.on_click(Play)],
[icon("play-fill", Alt("Play"))],
)
},
button.view(
"button-small",
[attribute.id("player-next"), event.on_click(NextTrack)],
[icon("skip-forward-fill", Alt("Next"))],
),
]),
p(
[
attribute.id("player-time-elapsed"),
attribute.class("player-time"),
attribute.attribute("aria-label", "Time elapsed"),
],
[track_length(model.position, current_time_padding)],
),
p(
[
attribute.id("player-time-total"),
attribute.class("player-time"),
attribute.attribute("aria-label", "Total time"),
],
[track_length(model.track.length, track_length.Auto)],
),
input([
attribute.id("player-track"),
attribute.type_("range"),
attribute.min("0"),
attribute.max(int.to_string(model.track.length)),
attribute.attribute("aria-label", "Track position"),
event.on("change", event_track_change),
event.on_mouse_down(StartUserSkip),
event.on_mouse_up(EndUserSkip),
event.on_input(event_track_input),
]),
audio(
[
attribute.id("player-elem"),
attribute.src(src),
attribute.controls(False),
attribute.autoplay(True),
attribute.attribute("crossorigin", "anonymous"),
event.on("play", event_play),
event.on("pause", event_pause),
event.on("ended", event_ended),
event.on("timeupdate", event_timeupdate),
],
[],
),
]),
]) ])
//FullScreen -> full_screen.view()
} }
fn form_url( fn form_url(
@ -350,29 +293,21 @@ fn form_url(
} }
fn event_play(_: a) { fn event_play(_: a) {
Ok(SetPlayState(Playing)) Ok(PlayStateUpdated(Playing))
} }
fn event_pause(_: a) { fn event_pause(_: a) {
Ok(SetPlayState(Paused)) Ok(PlayStateUpdated(Paused))
} }
fn event_ended(_: a) { fn event_ended(_: a) {
Ok(NextTrack) Ok(ActionTriggered(actions.NextTrack))
} }
fn event_timeupdate(_: a) { fn event_timeupdate(_: a) {
Ok(UpdateTime(current_time())) Ok(UpdateTime(current_time()))
} }
fn event_track_change(_: a) {
Ok(PositionSelected(current_track_position()))
}
fn event_track_input(_: a) {
PositionChanged(current_track_position())
}
fn start_play( fn start_play(
settings: common.Settings, settings: common.Settings,
artist: Artist, artist: Artist,

View file

@ -0,0 +1,15 @@
pub type PositionSelection {
Ephemeral
Commit
}
pub type Action {
NextTrack
PrevTrack
Play
Pause
Clear
StartUserSkip
EndUserSkip
SelectPosition(type_: PositionSelection)
}

View file

@ -0,0 +1,3 @@
pub fn view() {
todo
}

View file

@ -0,0 +1,45 @@
import gleam/uri
import gleam/option
import ibroadcast/authed_request.{type RequestConfig}
import elekf/web/common
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/library/artist.{type Artist}
import elekf/library/album.{type Album}
pub type AudioContext
pub type MediaElementAudioSourceNode
pub type GainNode
pub type PlayState {
Playing
Paused
}
pub type ViewMode {
Small
FullScreen
}
pub type Model {
Model(
settings: common.Settings,
library: Library,
track_id: Int,
track: Track,
artist: Artist,
album: Album,
url: uri.Uri,
position: Int,
state: PlayState,
loading_stream: Bool,
request_config: RequestConfig,
user_is_skipping: Bool,
audio_context: AudioContext,
audio_source: option.Option(MediaElementAudioSourceNode),
gain_node: GainNode,
view_mode: ViewMode,
)
}

View file

@ -0,0 +1,93 @@
import gleam/int
import gleam/option
import lustre/element.{text}
import lustre/element/html.{div, input, p}
import lustre/attribute
import lustre/event
import elekf/web/components/icon.{Alt, icon}
import elekf/web/components/track_length.{track_length}
import elekf/web/components/button_group
import elekf/web/components/button
import elekf/web/components/thumbnail
import elekf/web/components/player/actions.{
Commit, EndUserSkip, Ephemeral, NextTrack, Pause, Play, PrevTrack,
SelectPosition, StartUserSkip,
}
import elekf/web/components/player/model.{type Model}
pub fn view(model: Model) {
let is_playing = model.state == model.Playing
let current_time_padding = case model.track.length > 3600 {
True -> track_length.Hours
False -> track_length.Auto
}
div([attribute.id("small-player-wrapper")], [
thumbnail.maybe_view(
option.Some(model.settings),
"small-player-artwork",
"small-player-thumbnail",
"small-player-thumbnail small-player-thumbnail-placeholder",
model.track.artwork_id,
model.album.name,
),
p([attribute.id("small-player-track-name")], [text(model.track.title)]),
p([attribute.id("small-player-artist-name")], [text(model.artist.name)]),
p([attribute.id("small-player-album-name")], [text(model.album.name)]),
div([attribute.id("player-controls")], [
button_group.view("", [attribute.id("player-controls-buttons")], [
button.view(
"button-small",
[attribute.id("player-previous"), event.on_click(PrevTrack)],
[icon("skip-backward-fill", Alt("Previous"))],
),
case is_playing {
True ->
button.view(
"button-small",
[attribute.id("player-play-pause"), event.on_click(Pause)],
[icon("pause-fill", Alt("Pause"))],
)
False ->
button.view(
"button-small",
[attribute.id("player-play-pause"), event.on_click(Play)],
[icon("play-fill", Alt("Play"))],
)
},
button.view(
"button-small",
[attribute.id("player-next"), event.on_click(NextTrack)],
[icon("skip-forward-fill", Alt("Next"))],
),
]),
p(
[
attribute.id("player-time-elapsed"),
attribute.class("player-time"),
attribute.attribute("aria-label", "Time elapsed"),
],
[track_length(model.position, current_time_padding)],
),
p(
[
attribute.id("player-time-total"),
attribute.class("player-time"),
attribute.attribute("aria-label", "Total time"),
],
[track_length(model.track.length, track_length.Auto)],
),
input([
attribute.id("player-track"),
attribute.type_("range"),
attribute.min("0"),
attribute.max(int.to_string(model.track.length)),
attribute.attribute("aria-label", "Track position"),
event.on("change", fn(_) { Ok(SelectPosition(Commit)) }),
event.on_mouse_down(StartUserSkip),
event.on_mouse_up(EndUserSkip),
event.on_input(fn(_) { SelectPosition(Ephemeral) }),
]),
]),
])
}

View file

@ -8,20 +8,28 @@ import lustre/attribute
import ibroadcast/artwork import ibroadcast/artwork
import elekf/web/common.{type Settings} import elekf/web/common.{type Settings}
pub fn view(s: Settings, id: String, class: String) { pub fn view(s: Settings, image_id: String, html_id: String, class: String) {
div([attribute.class("thumbnail " <> class)], [ div(
img([ [
attribute.alt(""), attribute.id(html_id <> "-wrapper"),
attribute.src(artwork.url(s.artwork_server, id, artwork.S300)), attribute.class("thumbnail " <> class),
attribute.attribute("loading", "lazy"), ],
attribute.width(300), [
]), img([
]) attribute.id(html_id),
attribute.alt(""),
attribute.src(artwork.url(s.artwork_server, image_id, artwork.S300)),
attribute.attribute("loading", "lazy"),
attribute.width(300),
]),
],
)
} }
pub fn maybe_item_thumbnail(settings, artwork, placeholder) { pub fn maybe_item_thumbnail(settings, id, artwork, placeholder) {
maybe_view( maybe_view(
settings, settings,
id,
"library-item-thumbnail", "library-item-thumbnail",
"library-item-thumbnail library-item-thumbnail-placeholder", "library-item-thumbnail library-item-thumbnail-placeholder",
artwork, artwork,
@ -31,13 +39,15 @@ pub fn maybe_item_thumbnail(settings, artwork, placeholder) {
pub fn maybe_view( pub fn maybe_view(
settings: option.Option(Settings), settings: option.Option(Settings),
id: String,
class: String, class: String,
placeholder_class: String, placeholder_class: String,
artwork: option.Option(Int), artwork: option.Option(Int),
placeholder: String, placeholder: String,
) { ) {
case settings, artwork { case settings, artwork {
option.Some(s), option.Some(id) -> view(s, int.to_string(id), class) option.Some(s), option.Some(image_id) ->
view(s, int.to_string(image_id), id, class)
_, _ -> _, _ ->
div([attribute.class("thumbnail-placeholder " <> placeholder_class)], [ div([attribute.class("thumbnail-placeholder " <> placeholder_class)], [
text(placeholder), text(placeholder),

View file

@ -182,7 +182,7 @@ single-album-view {
display: none; display: none;
} }
#player-wrapper { #small-player-wrapper {
display: grid; display: grid;
grid-template: "art track" "art artist" "art album" "controls controls" auto / 2fr 8fr; grid-template: "art track" "art artist" "art album" "controls controls" auto / 2fr 8fr;
gap: var(--side-margin); gap: var(--side-margin);
@ -190,29 +190,29 @@ single-album-view {
font-weight: 100; font-weight: 100;
} }
#player-wrapper-track-name, #small-player-track-name,
#player-wrapper-artist-name, #small-player-artist-name,
#player-wrapper-album-name { #small-player-album-name {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow-x: hidden; overflow-x: hidden;
} }
#player-wrapper-track-name { #small-player-track-name {
grid-area: track; grid-area: track;
font-weight: normal; font-weight: normal;
} }
#player-wrapper-artist-name { #small-player-artist-name {
grid-area: artist; grid-area: artist;
} }
#player-wrapper-album-name { #small-player-album-name {
grid-area: album; grid-area: album;
} }
#player-wrapper .thumbnail { #small-player-artwork-wrapper {
grid-area: art; grid-area: art;
} }