Keep the last scroll position of views
This commit is contained in:
parent
6889053db3
commit
b107d3e6b8
14 changed files with 265 additions and 28 deletions
0
src/authed_view_ffi.mjs
Normal file
0
src/authed_view_ffi.mjs
Normal 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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -58,6 +58,7 @@ pub fn render(
|
|||
fn init() {
|
||||
let #(lib_m, lib_e) =
|
||||
library_view.init(
|
||||
component_name,
|
||||
library.empty(),
|
||||
True,
|
||||
data_getter,
|
||||
|
|
|
@ -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)) { [] },
|
||||
|
|
|
@ -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)) { [] },
|
||||
|
|
41
src/elekf/web/events/scroll_to.gleam
Normal file
41
src/elekf/web/events/scroll_to.gleam
Normal 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))
|
||||
}
|
|
@ -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)
|
||||
|
|
4
src/elekf/web/storage/history/models.gleam
Normal file
4
src/elekf/web/storage/history/models.gleam
Normal file
|
@ -0,0 +1,4 @@
|
|||
import gleam/dict
|
||||
|
||||
pub type ScrollPositions =
|
||||
dict.Dict(String, Float)
|
68
src/elekf/web/storage/history/storage.gleam
Normal file
68
src/elekf/web/storage/history/storage.gleam
Normal 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
36
src/library_view_ffi.mjs
Normal 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");
|
||||
}
|
13
style.css
13
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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue