From 9f1af761e5f095fbb48d37e1a1e6f501a1a8e7ff Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Tue, 27 Feb 2024 15:54:54 +0200 Subject: [PATCH] Initial work for full screen player --- src/elekf/web/authed_view.gleam | 10 +- .../components/library_views/album_item.gleam | 4 +- .../library_views/artists_view.gleam | 4 +- src/elekf/web/components/player.gleam | 295 +++++++----------- src/elekf/web/components/player/actions.gleam | 15 + .../web/components/player/full_screen.gleam | 3 + src/elekf/web/components/player/model.gleam | 45 +++ src/elekf/web/components/player/small.gleam | 93 ++++++ src/elekf/web/components/thumbnail.gleam | 32 +- style.css | 16 +- 10 files changed, 313 insertions(+), 204 deletions(-) create mode 100644 src/elekf/web/components/player/actions.gleam create mode 100644 src/elekf/web/components/player/full_screen.gleam create mode 100644 src/elekf/web/components/player/model.gleam create mode 100644 src/elekf/web/components/player/small.gleam diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index 293618f..888210b 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -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) { diff --git a/src/elekf/web/components/library_views/album_item.gleam b/src/elekf/web/components/library_views/album_item.gleam index 3ece9d4..dadb1ac 100644 --- a/src/elekf/web/components/library_views/album_item.gleam +++ b/src/elekf/web/components/library_views/album_item.gleam @@ -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, ), diff --git a/src/elekf/web/components/library_views/artists_view.gleam b/src/elekf/web/components/library_views/artists_view.gleam index 354e127..7586f2c 100644 --- a/src/elekf/web/components/library_views/artists_view.gleam +++ b/src/elekf/web/components/library_views/artists_view.gleam @@ -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, ), diff --git a/src/elekf/web/components/player.gleam b/src/elekf/web/components/player.gleam index 2ddd717..58223ba 100644 --- a/src/elekf/web/components/player.gleam +++ b/src/elekf/web/components/player.gleam @@ -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, diff --git a/src/elekf/web/components/player/actions.gleam b/src/elekf/web/components/player/actions.gleam new file mode 100644 index 0000000..66bbda3 --- /dev/null +++ b/src/elekf/web/components/player/actions.gleam @@ -0,0 +1,15 @@ +pub type PositionSelection { + Ephemeral + Commit +} + +pub type Action { + NextTrack + PrevTrack + Play + Pause + Clear + StartUserSkip + EndUserSkip + SelectPosition(type_: PositionSelection) +} diff --git a/src/elekf/web/components/player/full_screen.gleam b/src/elekf/web/components/player/full_screen.gleam new file mode 100644 index 0000000..2237cff --- /dev/null +++ b/src/elekf/web/components/player/full_screen.gleam @@ -0,0 +1,3 @@ +pub fn view() { + todo +} diff --git a/src/elekf/web/components/player/model.gleam b/src/elekf/web/components/player/model.gleam new file mode 100644 index 0000000..fb1f484 --- /dev/null +++ b/src/elekf/web/components/player/model.gleam @@ -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, + ) +} diff --git a/src/elekf/web/components/player/small.gleam b/src/elekf/web/components/player/small.gleam new file mode 100644 index 0000000..9cc53f3 --- /dev/null +++ b/src/elekf/web/components/player/small.gleam @@ -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) }), + ]), + ]), + ]) +} diff --git a/src/elekf/web/components/thumbnail.gleam b/src/elekf/web/components/thumbnail.gleam index 7438a1d..155e17b 100644 --- a/src/elekf/web/components/thumbnail.gleam +++ b/src/elekf/web/components/thumbnail.gleam @@ -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), diff --git a/style.css b/style.css index 6fb00c9..c7d5e07 100644 --- a/style.css +++ b/style.css @@ -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; }