Keep the last scroll position of views

This commit is contained in:
Mikko Ahlroth 2024-02-29 23:42:58 +02:00
parent 6889053db3
commit b107d3e6b8
14 changed files with 265 additions and 28 deletions

0
src/authed_view_ffi.mjs Normal file
View file

View file

@ -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);
}

View file

@ -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)

View file

@ -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)
}
})
}

View file

@ -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

View file

@ -58,6 +58,7 @@ pub fn render(
fn init() {
let #(lib_m, lib_e) =
library_view.init(
component_name,
library.empty(),
True,
data_getter,

View file

@ -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)) { [] },

View file

@ -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)) { [] },

View file

@ -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))
}

View file

@ -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) {
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)

View file

@ -0,0 +1,4 @@
import gleam/dict
pub type ScrollPositions =
dict.Dict(String, Float)

View file

@ -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)
}

36
src/library_view_ffi.mjs Normal file
View file

@ -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");
}

View file

@ -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;
}