From b107d3e6b8670288116457b814f1ab28a587e371 Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Thu, 29 Feb 2024 23:42:58 +0200 Subject: [PATCH] Keep the last scroll position of views --- src/authed_view_ffi.mjs | 0 src/custom_event_ffi.mjs | 14 +++- src/elekf/utils/custom_event.gleam | 7 +- src/elekf/web/authed_view.gleam | 22 +++++- src/elekf/web/components/library_view.gleam | 69 +++++++++++++++++-- .../library_views/albums_view.gleam | 1 + .../library_views/single_album_view.gleam | 1 + .../library_views/single_artist_view.gleam | 1 + src/elekf/web/events/scroll_to.gleam | 41 +++++++++++ src/elekf/web/events/start_play.gleam | 16 ++--- src/elekf/web/storage/history/models.gleam | 4 ++ src/elekf/web/storage/history/storage.gleam | 68 ++++++++++++++++++ src/library_view_ffi.mjs | 36 ++++++++++ style.css | 13 ++-- 14 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 src/authed_view_ffi.mjs create mode 100644 src/elekf/web/events/scroll_to.gleam create mode 100644 src/elekf/web/storage/history/models.gleam create mode 100644 src/elekf/web/storage/history/storage.gleam create mode 100644 src/library_view_ffi.mjs diff --git a/src/authed_view_ffi.mjs b/src/authed_view_ffi.mjs new file mode 100644 index 0000000..e69de29 diff --git a/src/custom_event_ffi.mjs b/src/custom_event_ffi.mjs index 70ec9c6..bce6ab6 100644 --- a/src/custom_event_ffi.mjs +++ b/src/custom_event_ffi.mjs @@ -1,3 +1,13 @@ -export function getDetail(e) { - return e.detail; +import { Ok, Error } from "./gleam.mjs"; + +export function newEvent(name, data) { + return new CustomEvent(name, { detail: data }); +} + +export function getDetail(e) { + if ("detail" in e) { + return new Ok(e.detail); + } + + return new Error(undefined); } diff --git a/src/elekf/utils/custom_event.gleam b/src/elekf/utils/custom_event.gleam index ba3d8a7..d077826 100644 --- a/src/elekf/utils/custom_event.gleam +++ b/src/elekf/utils/custom_event.gleam @@ -1,4 +1,9 @@ +import gleam/dynamic + pub type CustomEvent +@external(javascript, "../../custom_event_ffi.mjs", "newEvent") +pub fn new(name: String, data: any) -> CustomEvent + @external(javascript, "../../custom_event_ffi.mjs", "getDetail") -pub fn get_detail(e: CustomEvent) -> a +pub fn get_detail(e: CustomEvent) -> Result(dynamic.Dynamic, Nil) diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index 9980232..7e83bc2 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -13,6 +13,7 @@ import lustre/element.{text} import lustre/element/html.{div, nav, p} import lustre/attribute import lustre/effect +import lustre/event import birl import ibroadcast/library/library as library_api import ibroadcast/authed_request.{type RequestConfig, RequestConfig} @@ -84,6 +85,7 @@ pub type Msg { PlayerMsg(player.Msg) StartPlay(PlayQueue, Int) Router(router.Msg) + LibraryViewScrollToTopRequested } pub fn init(auth_data: common.AuthData) { @@ -240,6 +242,11 @@ pub fn update(model: Model, msg) { } } } + + LibraryViewScrollToTopRequested -> { + library_view.request_scroll(0.0) + #(model, effect.none()) + } } } @@ -261,19 +268,19 @@ pub fn view(model: Model) { link.button( router.to_hash(router.queryless(router.TrackList)), "", - [], + [maybe_scroll(model.view, library_view.Tracks)], [icon("music-note-beamed", Alt("Tracks"))], ), link.button( router.to_hash(router.queryless(router.ArtistList)), "", - [], + [maybe_scroll(model.view, library_view.Artists)], [icon("file-person", Alt("Artists"))], ), link.button( router.to_hash(router.queryless(router.AlbumList)), "", - [], + [maybe_scroll(model.view, library_view.Albums)], [icon("disc", Alt("Albums"))], ), ]), @@ -446,3 +453,12 @@ fn route_to_view(route: router.Route) { router.Settings -> Error(Nil) } } + +fn maybe_scroll(current_view: library_view.View, target_view: library_view.View) { + event.on("click", fn(_) { + case current_view == target_view { + True -> Ok(LibraryViewScrollToTopRequested) + False -> Error(Nil) + } + }) +} diff --git a/src/elekf/web/components/library_view.gleam b/src/elekf/web/components/library_view.gleam index 56ad8ba..beeaaa6 100644 --- a/src/elekf/web/components/library_view.gleam +++ b/src/elekf/web/components/library_view.gleam @@ -7,20 +7,24 @@ import gleam/list import gleam/dict import gleam/option import gleam/string +import gleam/result import lustre import lustre/effect import lustre/element import lustre/element/html.{div} import lustre/attribute import lustre/event +import elekf/utils/lustre as lustre_utils import elekf/utils/order.{type Sorter} import elekf/library.{type Library} import elekf/library/track.{type Track} import elekf/web/events/start_play +import elekf/web/events/scroll_to import elekf/web/components/search import elekf/web/components/library_item.{type LibraryItem} import elekf/web/components/shuffle_all import elekf/web/common +import elekf/web/storage/history/storage as history_store /// Function to get the data of the view from the library. pub type DataGetter(a) = @@ -69,10 +73,13 @@ pub type Msg { StartPlay(List(LibraryItem(Track)), Int) Search(search.Msg) FilterUpdated + ListScrolled(Float) + ScrollRequested(Float) } pub type Model(a) { Model( + id: String, library: Library, library_loading: Bool, data: List(LibraryItem(a)), @@ -81,6 +88,8 @@ pub type Model(a) { sorter: Sorter(a), search: search.Model, settings: option.Option(common.Settings), + history: history_store.StorageFormat, + history_api: history_store.HistoryStorage, ) } @@ -103,7 +112,7 @@ pub fn register( ) { lustre.component( name, - fn() { init(library.empty(), True, data_getter, shuffler, sorter) }, + fn() { init(name, library.empty(), True, data_getter, shuffler, sorter) }, update, generate_view(item_view, search_filter), generic_attributes(), @@ -134,9 +143,32 @@ pub fn render( ) } -pub fn init(library, library_loading, data_getter, shuffler, sorter) { +@external(javascript, "../../../library_view_ffi.mjs", "requestScroll") +pub fn request_scroll(pos: Float) -> Nil + +pub fn init(id, library, library_loading, data_getter, shuffler, sorter) { + let scrollend_effect = + effect.from(fn(dispatch) { + lustre_utils.after_next_render(fn() { + add_scrollend_listener(fn(pos) { dispatch(ListScrolled(pos)) }) + }) + }) + + let history_api = history_store.get_api() + let history = get_view_history(history_api) + + let scroll_to = dict.get(history.scrolls, id) + let scroll_to_effect = case scroll_to { + Ok(pos) if pos >. 0.0 -> + effect.from(fn(dispatch) { + lustre_utils.after_next_render(fn() { dispatch(ScrollRequested(pos)) }) + }) + _ -> effect.none() + } + #( Model( + id, library, library_loading, data_getter(library), @@ -145,8 +177,10 @@ pub fn init(library, library_loading, data_getter, shuffler, sorter) { sorter, search.init(), option.None, + history, + history_api, ), - effect.none(), + effect.batch([scrollend_effect, scroll_to_effect]), ) } @@ -169,6 +203,23 @@ pub fn update(model, msg) { } FilterUpdated -> #(update_data(model, model.library), effect.none()) + + ListScrolled(pos) -> { + let view_history = get_view_history(model.history_api) + let updated_history = + history_store.StorageFormat(scrolls: dict.insert( + view_history.scrolls, + model.id, + pos, + )) + let _ = history_store.write(model.history_api, updated_history) + #(model, effect.none()) + } + + ScrollRequested(pos) -> { + scroll_to(pos) + #(model, effect.none()) + } } } @@ -187,7 +238,7 @@ pub fn library_view( } div( - [attribute.class("library-list")], + [attribute.id("library-list"), scroll_to.on(ScrollRequested)], list.append( [ search.view(model.search) @@ -227,3 +278,13 @@ fn update_data(model: Model(a), library: Library) { |> list.sort(fn(a, b) { model.sorter(a.1, b.1) }), ) } + +fn get_view_history(history_api) { + result.unwrap(history_store.read(history_api), history_store.new()) +} + +@external(javascript, "../../../library_view_ffi.mjs", "addScrollendListener") +fn add_scrollend_listener(callback: fn(Float) -> Nil) -> Nil + +@external(javascript, "../../../library_view_ffi.mjs", "scrollTo") +fn scroll_to(pos: Float) -> Nil diff --git a/src/elekf/web/components/library_views/albums_view.gleam b/src/elekf/web/components/library_views/albums_view.gleam index d737349..ba5dc37 100644 --- a/src/elekf/web/components/library_views/albums_view.gleam +++ b/src/elekf/web/components/library_views/albums_view.gleam @@ -58,6 +58,7 @@ pub fn render( fn init() { let #(lib_m, lib_e) = library_view.init( + component_name, library.empty(), True, data_getter, diff --git a/src/elekf/web/components/library_views/single_album_view.gleam b/src/elekf/web/components/library_views/single_album_view.gleam index cc26522..058f58d 100644 --- a/src/elekf/web/components/library_views/single_album_view.gleam +++ b/src/elekf/web/components/library_views/single_album_view.gleam @@ -73,6 +73,7 @@ pub fn render( fn init() { let #(lib_m, lib_e) = library_view.init( + component_name, library.empty(), True, fn(_) -> List(LibraryItem(Track)) { [] }, diff --git a/src/elekf/web/components/library_views/single_artist_view.gleam b/src/elekf/web/components/library_views/single_artist_view.gleam index 54b6e15..7d46aed 100644 --- a/src/elekf/web/components/library_views/single_artist_view.gleam +++ b/src/elekf/web/components/library_views/single_artist_view.gleam @@ -73,6 +73,7 @@ pub fn render( fn init() { let #(lib_m, lib_e) = library_view.init( + component_name, library.empty(), True, fn(_) -> List(LibraryItem(Album)) { [] }, diff --git a/src/elekf/web/events/scroll_to.gleam b/src/elekf/web/events/scroll_to.gleam new file mode 100644 index 0000000..3e1ff7c --- /dev/null +++ b/src/elekf/web/events/scroll_to.gleam @@ -0,0 +1,41 @@ +import gleam/result +import gleam/dynamic +import lustre/event +import elekf/utils/custom_event.{type CustomEvent} + +pub const event_name = "scroll-to" + +pub type EventData { + EventData(pos: Float) +} + +pub fn emit(pos: Float) { + event.emit(event_name, EventData(pos)) +} + +pub fn on(msg: fn(Float) -> b) { + event.on(event_name, fn(data) { + data + |> decoder + |> result.map(fn(e) { msg(e.pos) }) + }) +} + +pub fn js_custom_event(pos: Float) { + custom_event.new(event_name, EventData(pos)) +} + +fn decoder(data: dynamic.Dynamic) { + let e: CustomEvent = dynamic.unsafe_coerce(data) + use detail <- result.try(custom_event.get_detail(e)) + use event_data <- result.try( + data_decoder()(detail) + |> result.nil_error(), + ) + + Ok(event_data) +} + +fn data_decoder() { + dynamic.decode1(EventData, dynamic.field("pos", dynamic.float)) +} diff --git a/src/elekf/web/events/start_play.gleam b/src/elekf/web/events/start_play.gleam index 7d9d5c5..bcc6332 100644 --- a/src/elekf/web/events/start_play.gleam +++ b/src/elekf/web/events/start_play.gleam @@ -15,22 +15,18 @@ pub fn emit(tracks: PlayQueue, position: Int) { } pub fn on(msg: fn(PlayQueue, Int) -> b) { - event.on( - event_name, - fn(data) { - data - |> decoder - |> result.map(fn(e) { msg(e.tracks, e.position) }) - }, - ) + event.on(event_name, fn(data) { + data + |> decoder + |> result.map(fn(e) { msg(e.tracks, e.position) }) + }) } fn decoder(data: dynamic.Dynamic) { let e: CustomEvent = dynamic.unsafe_coerce(data) - let detail = custom_event.get_detail(e) + let assert Ok(detail) = custom_event.get_detail(e) let event_data: EventData = detail - |> dynamic.from() |> dynamic.unsafe_coerce() Ok(event_data) diff --git a/src/elekf/web/storage/history/models.gleam b/src/elekf/web/storage/history/models.gleam new file mode 100644 index 0000000..0d58736 --- /dev/null +++ b/src/elekf/web/storage/history/models.gleam @@ -0,0 +1,4 @@ +import gleam/dict + +pub type ScrollPositions = + dict.Dict(String, Float) diff --git a/src/elekf/web/storage/history/storage.gleam b/src/elekf/web/storage/history/storage.gleam new file mode 100644 index 0000000..4f39477 --- /dev/null +++ b/src/elekf/web/storage/history/storage.gleam @@ -0,0 +1,68 @@ +//// The history storage stores information about visited views for restoring +//// when the user next opens them. + +import gleam/dynamic +import gleam/json +import gleam/dict +import gleam/list +import plinth/javascript/storage +import varasto +import elekf/web/storage/history/models + +const storage_key = "__elektrofoni_history_storage" + +const scrolls_key = "scroll-positions" + +/// Storage format of the view history data in local storage. +pub type StorageFormat { + StorageFormat(scrolls: models.ScrollPositions) +} + +/// The local storage API used for storing view history details. +pub type HistoryStorage = + varasto.TypedStorage(StorageFormat) + +/// Gets the `varasto` instance to use for reading and writing auth storage. +pub fn get_api() { + let assert Ok(local) = storage.local() + varasto.new(local, reader(), writer()) +} + +/// Reads the previously stored value, if available. +pub fn read(storage: HistoryStorage) { + varasto.get(storage, storage_key) +} + +/// Writes new value to the storage. +pub fn write(storage: HistoryStorage, data: StorageFormat) { + varasto.set(storage, storage_key, data) +} + +/// Create an empty storage model. +pub fn new() { + StorageFormat(scrolls: dict.new()) +} + +fn reader() { + dynamic.decode1(StorageFormat, dynamic.field(scrolls_key, scrolls_decoder())) +} + +fn writer() { + fn(val: StorageFormat) { + json.object([#(scrolls_key, scrolls_encoder(val.scrolls))]) + } +} + +fn scrolls_encoder(scrolls: models.ScrollPositions) { + scrolls + |> dict.to_list() + |> list.map(fn(item) { + let #(key, val) = item + #(key, json.float(val)) + }) + |> json.object() +} + +fn scrolls_decoder() { + dynamic.dict(dynamic.string, dynamic.float) +} diff --git a/src/library_view_ffi.mjs b/src/library_view_ffi.mjs new file mode 100644 index 0000000..39a7ded --- /dev/null +++ b/src/library_view_ffi.mjs @@ -0,0 +1,36 @@ +import { js_custom_event } from "./elekf/web/events/scroll_to.mjs"; + +export function requestScroll(pos) { + const el = getEl(); + if (el) { + const e = js_custom_event(pos); + el.dispatchEvent(e); + } +} + +export function addScrollendListener(callback) { + const el = getEl(); + if (el) { + el.addEventListener( + "scrollend", + () => { + callback(el.scrollTop); + }, + { passive: true } + ); + } +} + +export function scrollTo(pos) { + const el = getEl(); + if (el) { + el.scrollTo({ + top: pos, + behavior: "instant", + }); + } +} + +function getEl() { + return document.getElementById("library-list"); +} diff --git a/style.css b/style.css index 6ef5fcd..2afaa5c 100644 --- a/style.css +++ b/style.css @@ -411,19 +411,19 @@ single-album-view { font-size: 2rem; } -.library-list { +#library-list { height: 100%; overflow-y: auto; padding-bottom: 0; } -#authed-view-wrapper[data-player-status="open"] .library-list { +#authed-view-wrapper[data-player-status="open"] #library-list { padding-bottom: 100vh; } -#artists-view .library-list, -#albums-view .library-list, -#single-artist-view .library-list { +#artists-view #library-list, +#albums-view #library-list, +#single-artist-view #library-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-auto-flow: dense; @@ -457,9 +457,6 @@ single-album-view { background: var(--glass-background); } -.library-item img { -} - .library-item h3 { overflow-wrap: break-word; }