diff --git a/CHANGELOG.md b/CHANGELOG.md index 833a1453..71be97e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- A home screen editor allowing to enable/disable feeds, and change their order. + ## [0.18.1] - 2024-01-02 ### Fixed diff --git a/docs/playlet-web-api.yml b/docs/playlet-web-api.yml index ba081132..87ccc08d 100644 --- a/docs/playlet-web-api.yml +++ b/docs/playlet-web-api.yml @@ -153,7 +153,7 @@ paths: properties: index: type: integer - playlistIndex + playlistIndex: type: integer items: type: array @@ -178,7 +178,7 @@ paths: properties: index: type: integer - playlistIndex + playlistIndex: type: integer items: type: array @@ -227,6 +227,41 @@ paths: description: No Content "400": description: Bad Request + /api/home-layout: + get: + summary: Get home layout + description: Get the current home layout. This is the layout of the home screen, based on user preferences. + operationId: getHomeLayout + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + feedSources: + type: array + items: + type: object + properties: + apiType: + type: string + endpoint: + type: string + id: + type: string + queryParams: + type: object + additionalProperties: true + title: + type: string /api/playlet-lib-urls: get: summary: Get Playlet lib URLs diff --git a/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.bs b/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.bs index 7016fc03..1d7eb135 100644 --- a/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.bs +++ b/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.bs @@ -1,3 +1,4 @@ +import "HomeScreenUtils.bs" import "pkg:/components/ContextMenu/ContextMenuUtils.bs" import "pkg:/components/Navigation/Navigation.bs" import "pkg:/source/utils/FocusManagement.bs" @@ -14,7 +15,15 @@ function OnNodeReady() SetNavigation(invalid, "left", m.navBar) m.rowList@.BindNode() - m.rowList.feeds = ParseJson(ReadAsciiFile(m.top.feedFile)) + + ' TODO:P1 home screen should only be refreshed when the user navigates to it + ' (similar to bookmarks screen) + m.preferences.observeFieldScoped("misc.home_screen_layout", FuncName(OnHomeLayoutChange)) + OnHomeLayoutChange() +end function + +function OnHomeLayoutChange() as void + m.rowList.feeds = HomeScreenUtils.GetFeed(m.top.feedFile, m.preferences) end function function OnFocusChange() as void diff --git a/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.xml b/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.xml index 1bc3da92..65778c7b 100644 --- a/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.xml +++ b/playlet-lib/src/components/Screens/HomeScreen/HomeScreen.xml @@ -2,6 +2,7 @@ + diff --git a/playlet-lib/src/components/Screens/HomeScreen/HomeScreenUtils.bs b/playlet-lib/src/components/Screens/HomeScreen/HomeScreenUtils.bs new file mode 100644 index 00000000..41edfc97 --- /dev/null +++ b/playlet-lib/src/components/Screens/HomeScreen/HomeScreenUtils.bs @@ -0,0 +1,28 @@ +import "pkg:/source/utils/Types.bs" + +namespace HomeScreenUtils + + function GetFeed(feedFileName as string, preferences as object) as object + feed = ParseJson(ReadAsciiFile(feedFileName)) + + homeLayout = preferences["misc.home_screen_layout"] + if not IsArray(homeLayout) or homeLayout.Count() = 0 + return feed + end if + + feedItems = {} + for each item in feed + feedItems[item["id"]] = item + end for + + filteredFeed = [] + for each item in homeLayout + if item.enabled = true + filteredFeed.push(feedItems[item.id]) + end if + end for + + return filteredFeed + end function + +end namespace diff --git a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.bs b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.bs new file mode 100644 index 00000000..b4c98019 --- /dev/null +++ b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.bs @@ -0,0 +1,58 @@ +import "pkg:/components/parts/AutoBind/OnNodeReadyNoOp.bs" +import "pkg:/source/utils/FocusManagement.bs" +import "pkg:/source/utils/Types.bs" + +function Init() + m.top.focusable = true + m.top.itemSpacings = [8] + + m.button = m.top.findNode("button") + m.button.observeField("buttonSelected", FuncName(OpenHomeScreenEditor)) +end function + +function OnFocusChange() as void + if not m.top.focus + return + end if + + NodeSetFocus(m.button, true) +end function + +function BindPreference(preferences as object, key as string) + if m.preferences <> invalid and m.key <> invalid + m.preferences.unobserveFieldScoped(m.key) + end if + + m.preferences = preferences + m.key = key + + if preferences <> invalid and key <> invalid + preferences.observeFieldScoped(key, FuncName(OnPreferenceChange)) + OnPreferenceChange() + end if +end function + +function OpenHomeScreenEditor() + editor = CreateObject("roSGNode", "HomeScreenEditor") + m.appController@.PushScreen(editor) + editor@.BindNode() + editor.value = m.top.value + editor.observeField("save", FuncName(OnSaveHomeScreenEditor)) +end function + +function OnSaveHomeScreenEditor(event as object) + editor = event.GetRoSGNode() + m.top.value = editor.value +end function + +function OnPreferenceChange() + m.top.value = m.preferences[m.key] +end function + +function OnValueChange() as void + if m.preferences = invalid or m.key = invalid + return + end if + + m.preferences[m.key] = m.top.value +end function diff --git a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.xml b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.xml new file mode 100644 index 00000000..30f0b728 --- /dev/null +++ b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/EditHomeScreenControl.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.bs b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.bs new file mode 100644 index 00000000..7a331a94 --- /dev/null +++ b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.bs @@ -0,0 +1,156 @@ +import "pkg:/components/Navigation/Navigation.bs" +import "pkg:/components/parts/AutoBind/OnNodeReadyNoOp.bs" +import "pkg:/source/utils/MathUtils.bs" +import "pkg:/source/utils/Types.bs" + +function Init() + m.homeLayout = {} + homeLayout = ParseJson(ReadAsciiFile("libpkg:/config/default_home_layout.yaml")) + for each item in homeLayout + m.homeLayout[item.id] = item + end for + + m.checkList = m.top.findNode("checkList") + m.moveUpButton = m.top.findNode("moveUpButton") + m.moveDownButton = m.top.findNode("moveDownButton") + m.closeButton = m.top.findNode("closeButton") + m.saveButton = m.top.findNode("saveButton") + + SetNavigation(m.checkList, "down", m.saveButton) + SetNavigation(m.saveButton, "up", m.checkList) + SetNavigation(m.closeButton, "up", m.checkList) + SetNavigation(m.saveButton, "right", m.closeButton) + SetNavigation(m.closeButton, "left", m.saveButton) + SetNavigation(m.moveUpButton, "left", m.checkList) + SetNavigation(m.moveDownButton, "left", m.checkList) + SetNavigation(m.checkList, "right", m.moveUpButton) + SetNavigation(m.moveUpButton, "down", m.moveDownButton) + SetNavigation(m.moveDownButton, "up", m.moveUpButton) + SetNavigation(m.moveDownButton, "down", m.closeButton) + + m.moveUpButton.observeField("buttonSelected", FuncName(OnMoveUpButtonSelected)) + m.moveDownButton.observeField("buttonSelected", FuncName(OnMoveDownButtonSelected)) + m.saveButton.observeField("buttonSelected", FuncName(OnSaveButtonSelected)) + m.closeButton.observeField("buttonSelected", FuncName(Close)) + + m.checkList.observeField("checkedState", FuncName(OnCheckedStateChange)) +end function + +function OnFocusChange() as void + if not m.top.focus + return + end if + + NodeSetFocus(m.checkList, true) +end function + +function OnkeyEvent(key as string, press as boolean) as boolean + if NavigationKeyHandler(key, press).handled + return true + end if + + if key = "options" and press + ' A pass-through to the app controller, so it can toggle picture-in-picture + return false + end if + + if key = "back" and press + Close() + return true + end if + + return true +end function + +function OnValueChange() + content = m.checkList.content + value = m.top.value + + nodes = [] + checkedState = [] + for each item in value + node = CreateObject("roSGNode", "ContentNode") + node.id = item.id + node.title = m.homeLayout[item.id].title + nodes.push(node) + checkedState.push(item.enabled) + end for + + labelCount = content.getChildCount() + if labelCount > 0 + content.removeChildrenIndex(labelCount, 0) + end if + + content.appendChildren(nodes) + m.checkList.checkedState = checkedState +end function + +function OnCheckedStateChange() as void + content = m.checkList.content + if content = invalid or content.getChildCount() = 0 + return + end if + checkedState = m.checkList.checkedState + if checkedState = invalid or checkedState.Count() = 0 + return + end if + + value = [] + checkboxes = content.getChildren(-1, 0) + + for i = 0 to checkboxes.Count() - 1 + checkbox = checkboxes[i] + value.push({ + id: checkbox.id + enabled: checkedState[i] + }) + end for + + m.top.value = value +end function + +function OnMoveUpButtonSelected() as void + MoveItem(-1) +end function + +function OnMoveDownButtonSelected() as void + MoveItem(1) +end function + +function MoveItem(offset as integer) as void + content = m.checkList.content + if content = invalid or content.getChildCount() = 0 + return + end if + + itemCount = content.getChildCount() + index = m.checkList.itemFocused + newIndex = MathUtils.Max(0, MathUtils.Min(index + offset, itemCount - 1)) + + if index = newIndex + return + end if + + checkedState = m.checkList.checkedState + node = content.getChild(index) + content.insertChild(node, newIndex) + + tmp = checkedState[index] + checkedState[index] = checkedState[newIndex] + checkedState[newIndex] = tmp + + m.checkList.itemFocused = newIndex + m.checkList.jumpToItem = newIndex + m.checkList.checkedState = checkedState +end function + +function OnSaveButtonSelected() + ' Save the new layout to m.top.value + OnCheckedStateChange() + m.top.save = true + Close() +end function + +function Close() + m.appController@.PopScreen() +end function diff --git a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml new file mode 100644 index 00000000..f50e4961 --- /dev/null +++ b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/playlet-lib/src/components/Services/Preferences/Preferences.bs b/playlet-lib/src/components/Services/Preferences/Preferences.bs index 148041c7..822492db 100644 --- a/playlet-lib/src/components/Services/Preferences/Preferences.bs +++ b/playlet-lib/src/components/Services/Preferences/Preferences.bs @@ -2,6 +2,13 @@ import "pkg:/source/utils/RegistryUtils.bs" import "pkg:/source/utils/Types.bs" function Init() + m.typeMapping = { + "boolean": "boolean" + "string": "string" + "radio": "string" + "array": "array" + } + m.disableSavingToRegistry = false LoadPreferencesFile() AddNodeFields() @@ -25,14 +32,7 @@ end function function AddNodeFieldsFromPreference(pref as object, fields as object) if pref.key <> invalid and pref.type <> invalid - fieldType = "" - if pref.type = "boolean" - fieldType = "boolean" - else if pref.type = "string" - fieldType = "string" - else if pref.type = "radio" - fieldType = "string" - end if + fieldType = m.typeMapping[pref.type] m.top.addField(pref.key, fieldType, false) fields[pref.key] = true end if diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/HomeLayoutRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/HomeLayoutRouter.bs new file mode 100644 index 00000000..ab1aaf98 --- /dev/null +++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/HomeLayoutRouter.bs @@ -0,0 +1,25 @@ +import "pkg:/components/Screens/HomeScreen/HomeScreenUtils.bs" + +namespace Http + + class HomeLayoutRouter extends HttpRouter + + function new() + super() + + m.Get("/api/home-layout", function(context as object) as boolean + response = context.response + task = context.server.task + preferences = task.preferences + + feed = HomeScreenUtils.GetFeed("libpkg:/config/default_home_layout.yaml", preferences) + response.Json(feed) + + return true + end function) + + end function + + end class + +end namespace diff --git a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs index 7a6cddcd..2b66b104 100644 --- a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs +++ b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs @@ -1,6 +1,7 @@ import "pkg:/components/parts/AutoBind/OnNodeReadyNoOp.bs" import "pkg:/components/Web/PlayletWebServer/Middleware/BookmarksRouter.bs" import "pkg:/components/Web/PlayletWebServer/Middleware/DashRouter.bs" +import "pkg:/components/Web/PlayletWebServer/Middleware/HomeLayoutRouter.bs" import "pkg:/components/Web/PlayletWebServer/Middleware/InvidiousRouter.bs" import "pkg:/components/Web/PlayletWebServer/Middleware/PlayletLibUrlsRouter.bs" import "pkg:/components/Web/PlayletWebServer/Middleware/PlayQueueRouter.bs" @@ -46,6 +47,7 @@ function SetupRoutes(server as object) server.UseRouter(new Http.InvidiousRouter()) server.UseRouter(new Http.PlayQueueRouter()) server.UseRouter(new Http.BookmarksRouter()) + server.UseRouter(new Http.HomeLayoutRouter()) server.UseRouter(new Http.ViewRouter()) server.UseRouter(new Http.SearchHistoryRouter()) server.UseRouter(new Http.PlayletLibUrlsRouter()) diff --git a/playlet-lib/src/config/default_home_layout.yaml b/playlet-lib/src/config/default_home_layout.yaml index b0cf0317..c255d56e 100644 --- a/playlet-lib/src/config/default_home_layout.yaml +++ b/playlet-lib/src/config/default_home_layout.yaml @@ -1,4 +1,5 @@ - title: Subscriptions + id: subscriptions feedSources: - id: inv_auth_feed title: Subscriptions @@ -6,6 +7,7 @@ endpoint: auth_feed - title: Trending + id: trending feedSources: - id: inv_trending title: Trending @@ -13,6 +15,7 @@ endpoint: trending - title: Trending - Music + id: trending_music feedSources: - id: inv_trending_music title: Trending - Music @@ -22,6 +25,7 @@ type: Music - title: Trending - Gaming + id: trending_gaming feedSources: - id: inv_trending_gaming title: Trending - Gaming @@ -31,6 +35,7 @@ type: Gaming - title: Trending - Movies + id: trending_movies feedSources: - id: inv_trending_movies title: Trending - Movies @@ -40,6 +45,7 @@ type: Movies - title: Popular + id: popular feedSources: - id: inv_popular title: Popular @@ -47,6 +53,7 @@ endpoint: popular - title: Playlists + id: playlists feedSources: - id: inv_auth_playlists title: Playlists @@ -54,6 +61,7 @@ endpoint: auth_playlists - title: Watch history + id: watch_history feedSources: - id: inv_auth_history title: Watch history diff --git a/playlet-lib/src/config/preferences.json5 b/playlet-lib/src/config/preferences.json5 index 2192a530..41fed3e4 100644 --- a/playlet-lib/src/config/preferences.json5 +++ b/playlet-lib/src/config/preferences.json5 @@ -130,6 +130,55 @@ key: "misc", description: "Misc. preferences", children: [ + { + displayText: "Edit home screen", + key: "misc.home_screen_layout", + description: "Edit the home screen feed", + type: "array", + defaultValue: [ + { + id: "subscriptions", + enabled: true, + }, + { + id: "trending", + enabled: true, + }, + { + id: "trending_music", + enabled: true, + }, + { + id: "trending_gaming", + enabled: true, + }, + { + id: "trending_movies", + enabled: true, + }, + { + id: "popular", + enabled: true, + }, + { + id: "playlists", + enabled: true, + }, + { + id: "watch_history", + enabled: true, + }, + ], + visibility: "tv", + rokuComponent: "EditHomeScreenControl", + }, + { + displayText: "Edit home screen", + key: "misc.home_screen_layout", + description: "Edit the home screen feed", + visibility: "web", + svelteComponent: "EditHomeScreenControl", + }, { displayText: "Queue notifications", key: "misc.queue_notifications", diff --git a/playlet-web/src/App.svelte b/playlet-web/src/App.svelte index 88c379a4..8312149d 100644 --- a/playlet-web/src/App.svelte +++ b/playlet-web/src/App.svelte @@ -6,6 +6,7 @@ appStateStore, bookmarksStore, homeLayoutFileStore, + homeLayoutStore, invidiousVideoApiStore, playletStateStore, preferencesModelStore, @@ -27,6 +28,10 @@ playletStateStore.set(value); }); + PlayletApi.getHomeLayout().then((value) => { + homeLayoutStore.set(value); + }); + PlayletApi.getHomeLayoutFile().then((value) => { homeLayoutFileStore.set(value); }); diff --git a/playlet-web/src/lib/Api/PlayletApi.ts b/playlet-web/src/lib/Api/PlayletApi.ts index eaa08757..c97cc0a6 100644 --- a/playlet-web/src/lib/Api/PlayletApi.ts +++ b/playlet-web/src/lib/Api/PlayletApi.ts @@ -13,6 +13,12 @@ export class PlayletApi { return await response.json(); } + // Home layout is the home layout file, but with the user's preferences applied. + static async getHomeLayout() { + const response = await fetch(`${PlayletApi.host()}/api/home-layout`); + return await response.json(); + } + static async getHomeLayoutFile() { const response = await fetch(`${PlayletApi.host()}/config/default_home_layout.yaml`); return await response.json(); diff --git a/playlet-web/src/lib/Screens/HomeScreen.svelte b/playlet-web/src/lib/Screens/HomeScreen.svelte index b3cfec31..c59f8aaf 100644 --- a/playlet-web/src/lib/Screens/HomeScreen.svelte +++ b/playlet-web/src/lib/Screens/HomeScreen.svelte @@ -1,13 +1,13 @@ - {#each $homeLayoutFileStore as feed} + {#each $homeLayoutStore as feed} {/each} diff --git a/playlet-web/src/lib/Screens/Settings/SettingControls/EditHomeScreenControl.svelte b/playlet-web/src/lib/Screens/Settings/SettingControls/EditHomeScreenControl.svelte new file mode 100644 index 00000000..da568c7e --- /dev/null +++ b/playlet-web/src/lib/Screens/Settings/SettingControls/EditHomeScreenControl.svelte @@ -0,0 +1,144 @@ + + + + {displayText} + {@html description} + Edit home screen + + + + + {#if value && homeLayout} + + {#each value as item, i (i)} + + + + moveUp(i)} + disabled={i === 0} + > + + + + + + + {homeLayout[item.id].title} + + + + + + moveDown(i)} + disabled={i === value.length - 1} + > + + + + + + {/each} + + + Save + + {/if} + + + Close + + diff --git a/playlet-web/src/lib/Screens/Settings/SettingsNode.svelte b/playlet-web/src/lib/Screens/Settings/SettingsNode.svelte index b2f6012c..e2a1eb6c 100644 --- a/playlet-web/src/lib/Screens/Settings/SettingsNode.svelte +++ b/playlet-web/src/lib/Screens/Settings/SettingsNode.svelte @@ -1,6 +1,7 @@ diff --git a/playlet-web/src/lib/Stores.ts b/playlet-web/src/lib/Stores.ts index 035f51fa..cda0be34 100644 --- a/playlet-web/src/lib/Stores.ts +++ b/playlet-web/src/lib/Stores.ts @@ -15,6 +15,8 @@ export const userPreferencesStore = writable({} as any); export const invidiousVideoApiStore = writable({} as any); +export const homeLayoutStore = writable([] as any); + export const homeLayoutFileStore = writable([] as any); export const bookmarksStore = writable([] as any); diff --git a/playlet-web/src/lib/VideoFeed/VideoListRow.svelte b/playlet-web/src/lib/VideoFeed/VideoListRow.svelte index 389cbe7d..d6a02824 100644 --- a/playlet-web/src/lib/VideoFeed/VideoListRow.svelte +++ b/playlet-web/src/lib/VideoFeed/VideoListRow.svelte @@ -7,7 +7,7 @@ // TODO:P1 figure out why some uncached feeds (e.g. channels/ucid/videos) get hit twice export let feed: any = undefined; - export let videos = []; + let videos = []; enum FeedLoadState { None, @@ -28,6 +28,8 @@ let scrollStart = 0; let scrollEnd = 0; + let loadDataTask = undefined; + // w-80|w-60 p-2: 320px|240px + 16px padding on each side const videoItemWidth = 320 + 16 * 2; const channelItemWidth = 240 + 16 * 2; @@ -42,6 +44,18 @@ { threshold: [0] } ); + $: { + if (feed) { + feedSourcesIndex = 0; + feedLoadState = FeedLoadState.None; + videos = []; + itemWidths = []; + scrollStart = 0; + scrollEnd = 0; + loadDataTask = undefined; + } + } + $: { if ( carouselElement && @@ -112,8 +126,6 @@ loadDataTask = undefined; } - let loadDataTask; - async function loadData() { feedLoadState = FeedLoadState.Loading; let totalFetchedItems = 0;