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/web/router
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_views/tracks_view
import elekf/web/components/library_views/artists_view
@ -52,7 +54,7 @@ pub type PlayInfo {
track: Track,
play_queue: PlayQueue,
play_index: Int,
player: player.Model,
player: player_model.Model,
current_track_status: CurrentTrackStatus,
)
}
@ -156,10 +158,12 @@ pub fn update(model: Model, msg) {
let #(status, effect) = handle_start_play(model, tracks, position)
#(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) {
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
}
case list.at(info.play_queue, next_index) {

View file

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

View file

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

View file

@ -5,8 +5,8 @@ import gleam/int
import gleam/float
import gleam/option
import gleam/io
import lustre/element.{text}
import lustre/element/html.{audio, div, input, p}
import lustre/element
import lustre/element/html.{audio, div}
import lustre/attribute
import lustre/event
import lustre/effect
@ -14,74 +14,38 @@ import plinth/browser/media/metadata
import plinth/browser/media/session
import plinth/browser/media/action
import plinth/browser/media/position
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/streaming
import ibroadcast/artwork
import elektrofoni
import elekf/utils/lustre
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/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/track.{type Track}
import elekf/library/artist.{type Artist}
import elekf/library/album.{type Album}
import elekf/utils/date
import ibroadcast/authed_request.{type RequestConfig}
import ibroadcast/streaming
import ibroadcast/artwork
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 {
StartPlay(Int, Track)
NextTrack
PrevTrack
Play
Pause
SetPlayState(PlayState)
Clear
PlayStateUpdated(PlayState)
UpdateTime(Float)
UpdateRequestConfig(RequestConfig)
StartUserSkip
EndUserSkip
PositionSelected(Int)
PositionChanged(Int)
SeekBackward(Float)
SeekForward(Float)
BackwardSeekRequested(Float)
ForwardSeekRequested(Float)
CreateMediaElementAudioSourceNode
FullScreenToggled
ActionTriggered(actions.Action)
PositionSelected(Int)
}
pub fn init(
@ -96,17 +60,33 @@ pub fn init(
let action_effect =
effect.from(fn(dispatch) {
register_action_handler(action.NextTrack, NextTrack, dispatch)
register_action_handler(action.PreviousTrack, PrevTrack, dispatch)
register_action_handler(action.Play, Play, dispatch)
register_action_handler(action.Pause, Pause, dispatch)
register_action_handler(
action.NextTrack,
ActionTriggered(actions.NextTrack),
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) {
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) {
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) {
let seek_to = option.unwrap(data.seek_time, 0.0)
@ -138,6 +118,7 @@ pub fn init(
audio_context,
option.None,
create_gain(audio_context),
model.Small,
),
action_effect,
)
@ -145,6 +126,39 @@ pub fn init(
pub fn update(model: Model, 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) -> {
let artist = library.assert_artist(model.library, track.artist_id)
let album = library.assert_album(model.library, track.album_id)
@ -170,21 +184,10 @@ pub fn update(model: Model, msg) {
effect.none(),
)
}
Play -> {
play()
#(model, effect.none())
}
Pause -> {
pause()
#(model, effect.none())
}
SetPlayState(play_state) -> #(
PlayStateUpdated(play_state) -> #(
Model(..model, state: play_state),
effect.none(),
)
NextTrack -> #(model, effect.none())
PrevTrack -> #(model, effect.none())
Clear -> #(model, effect.none())
UpdateTime(time) ->
case model.user_is_skipping {
True -> #(model, effect.none())
@ -214,123 +217,63 @@ pub fn update(model: Model, msg) {
Model(..model, request_config: config),
effect.none(),
)
StartUserSkip -> #(Model(..model, user_is_skipping: True), effect.none())
EndUserSkip -> #(Model(..model, user_is_skipping: False), effect.none())
BackwardSeekRequested(amount) -> {
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) -> {
skip_to_time(pos)
#(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 -> {
let source = create_media_element_source(model.audio_context)
connect_gain(model.gain_node, model.audio_context, source)
#(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) {
let is_playing = model.state == Playing
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")], [
thumbnail.maybe_view(
option.Some(model.settings),
"player-wrapper-thumbnail",
"player-wrapper-thumbnail player-wrapper-thumbnail-placeholder",
model.track.artwork_id,
model.album.name,
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),
],
[],
),
p([attribute.id("player-wrapper-track-name")], [text(model.track.title)]),
p([attribute.id("player-wrapper-artist-name")], [text(model.artist.name)]),
p([attribute.id("player-wrapper-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", 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),
],
[],
),
]),
case model.view_mode {
model.Small ->
small.view(model)
|> element.map(ActionTriggered)
},
])
//FullScreen -> full_screen.view()
}
fn form_url(
@ -350,29 +293,21 @@ fn form_url(
}
fn event_play(_: a) {
Ok(SetPlayState(Playing))
Ok(PlayStateUpdated(Playing))
}
fn event_pause(_: a) {
Ok(SetPlayState(Paused))
Ok(PlayStateUpdated(Paused))
}
fn event_ended(_: a) {
Ok(NextTrack)
Ok(ActionTriggered(actions.NextTrack))
}
fn event_timeupdate(_: a) {
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(
settings: common.Settings,
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 elekf/web/common.{type Settings}
pub fn view(s: Settings, id: String, class: String) {
div([attribute.class("thumbnail " <> class)], [
img([
attribute.alt(""),
attribute.src(artwork.url(s.artwork_server, id, artwork.S300)),
attribute.attribute("loading", "lazy"),
attribute.width(300),
]),
])
pub fn view(s: Settings, image_id: String, html_id: String, class: String) {
div(
[
attribute.id(html_id <> "-wrapper"),
attribute.class("thumbnail " <> class),
],
[
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(
settings,
id,
"library-item-thumbnail",
"library-item-thumbnail library-item-thumbnail-placeholder",
artwork,
@ -31,13 +39,15 @@ pub fn maybe_item_thumbnail(settings, artwork, placeholder) {
pub fn maybe_view(
settings: option.Option(Settings),
id: String,
class: String,
placeholder_class: String,
artwork: option.Option(Int),
placeholder: String,
) {
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)], [
text(placeholder),

View file

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