feat(library): focused music/TV/movie landing screens + self-draining download queue
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
@@ -2,9 +2,19 @@
|
||||
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/**
|
||||
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
|
||||
* Carries the merged cache+server results for a given search request.
|
||||
*/
|
||||
interface SearchUpdateEvent {
|
||||
requestId: number;
|
||||
result: SearchResult;
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
|
||||
interface LibraryState {
|
||||
@@ -47,8 +57,23 @@ function createLibraryStore() {
|
||||
|
||||
const { subscribe, set, update } = writable<LibraryState>(initialState);
|
||||
|
||||
// Test log to confirm cache logging is active
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
// Monotonic id identifying the most recent search request. Each new search
|
||||
// bumps it; the deferred `search-event` (carrying merged cache+server
|
||||
// results) is only applied when its requestId still matches the latest one,
|
||||
// so out-of-order / superseded results never clobber fresher ones.
|
||||
let searchRequestId = 0;
|
||||
let unlistenSearch: UnlistenFn | null = null;
|
||||
|
||||
// Lazily subscribe to backend search updates the first time we search.
|
||||
async function ensureSearchListener() {
|
||||
if (unlistenSearch) return;
|
||||
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
|
||||
const { requestId, result } = event.payload;
|
||||
// Ignore results from a query the user has already moved on from.
|
||||
if (requestId !== searchRequestId) return;
|
||||
update((s) => ({ ...s, searchResults: result.items }));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
@@ -157,11 +182,17 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
// Bump the request id for every call (including clears) so any in-flight
|
||||
// backend update for a previous query is ignored when it arrives.
|
||||
const requestId = ++searchRequestId;
|
||||
|
||||
if (!query.trim()) {
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSearchListener();
|
||||
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
@@ -172,16 +203,21 @@ function createLibraryStore() {
|
||||
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
|
||||
);
|
||||
|
||||
// Phase 1: the command resolves with instant local-cache results. The
|
||||
// merged (cache + server) union arrives later via the `search-event`
|
||||
// listener above, tagged with this same requestId.
|
||||
const result = await Promise.race([
|
||||
repo.search(query, { limit: 10000 }),
|
||||
repo.search(query, { limit: 10000 }, requestId),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
// Only apply if this is still the active query (a newer search may have
|
||||
// started while we awaited).
|
||||
if (requestId === searchRequestId) {
|
||||
update((s) => ({ ...s, searchResults: result.items }));
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -196,6 +232,8 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
// Invalidate any in-flight backend search update.
|
||||
searchRequestId++;
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
}
|
||||
|
||||
@@ -247,6 +285,11 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
searchRequestId++;
|
||||
if (unlistenSearch) {
|
||||
unlistenSearch();
|
||||
unlistenSearch = null;
|
||||
}
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Movies library landing page data store.
|
||||
// Powers the focused movies landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/** A single "by genre" row: the genre name plus the movies in it. */
|
||||
export interface GenreRow {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
interface MoviesState {
|
||||
// Movies the user can resume (continue watching).
|
||||
continueWatching: MediaItem[];
|
||||
// Recently added movies in the library.
|
||||
recentlyAdded: MediaItem[];
|
||||
// One slider per genre (top genres by movie count).
|
||||
genreRows: GenreRow[];
|
||||
// Mix used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
// How many genre sliders to show, and how many genres to probe to find them.
|
||||
const MAX_GENRE_ROWS = 8;
|
||||
const MAX_GENRES_PROBED = 20;
|
||||
|
||||
function createMoviesStore() {
|
||||
const initialState: MoviesState = {
|
||||
continueWatching: [],
|
||||
recentlyAdded: [],
|
||||
genreRows: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<MoviesState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation. Prefer in-progress movies (most personal), then
|
||||
* fall back to recently added. De-duplicates by id and prefers items that
|
||||
* carry backdrop/primary artwork for a good banner.
|
||||
*/
|
||||
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pool of [continueWatching, recentlyAdded]) {
|
||||
for (const item of pool) {
|
||||
if (result.length >= 6) break;
|
||||
if (hasArt(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, latest] = await Promise.all([
|
||||
repo.getResumeMovies(SECTION_LIMIT),
|
||||
repo.getLatestItems(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
const heroItems = buildHero(resume, latest);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
continueWatching: resume,
|
||||
recentlyAdded: latest,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
// Genre rows are secondary — load them after the main sections paint so
|
||||
// the page isn't blocked on N per-genre queries.
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load movie sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load movie sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one slider per genre, showing the top-rated movies in each. We probe
|
||||
* a bounded set of genres in parallel, drop empty ones, then keep the genres
|
||||
* with the most movies (so niche/near-empty genres don't crowd the page).
|
||||
*/
|
||||
async function loadGenreRows(libraryId: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(libraryId);
|
||||
if (genres.length === 0) return;
|
||||
|
||||
const probed = genres.slice(0, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(
|
||||
probed.map(async (genre): Promise<GenreRow> => {
|
||||
try {
|
||||
const result = await repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Movie"],
|
||||
genres: [genre.name],
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
});
|
||||
return { id: genre.id, name: genre.name, items: result.items };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load movie genre rows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const movies = createMoviesStore();
|
||||
|
||||
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
|
||||
export const isMoviesLoading = derived(movies, $m => $m.isLoading);
|
||||
@@ -0,0 +1,134 @@
|
||||
// Music library landing page data store.
|
||||
// Powers the focused music landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
|
||||
interface MusicState {
|
||||
// Albums grouped from recently played tracks (backend handles grouping).
|
||||
recentlyPlayed: MediaItem[];
|
||||
// Most recently added albums in the music library.
|
||||
newlyAdded: MediaItem[];
|
||||
// User playlists.
|
||||
playlists: MediaItem[];
|
||||
// Albums the user has played but hasn't returned to in a while.
|
||||
rediscover: MediaItem[];
|
||||
// Mix of recently played + rediscover, used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
|
||||
function createMusicStore() {
|
||||
const initialState: MusicState = {
|
||||
recentlyPlayed: [],
|
||||
newlyAdded: [],
|
||||
playlists: [],
|
||||
rediscover: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<MusicState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation from a mix of recently played and rediscover
|
||||
* albums, interleaved so the banner alternates "fresh in your ears" with
|
||||
* "remember this?". De-duplicates by id and prefers items that have artwork.
|
||||
*/
|
||||
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
|
||||
|
||||
const recentPool = recent.filter(hasArt);
|
||||
const rediscoverPool = rediscover.filter(hasArt);
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
|
||||
|
||||
for (let i = 0; i < maxLen && result.length < 6; i++) {
|
||||
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
|
||||
if (candidate && !seen.has(candidate.id)) {
|
||||
seen.add(candidate.id);
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([
|
||||
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
// HACK: drop the "Podcasts" folder that lives inside the music library.
|
||||
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
|
||||
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
|
||||
const playlistItems = excludePodcasts(playlistsResult.items);
|
||||
const rediscoverAlbums = excludePodcasts(rediscover);
|
||||
|
||||
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
recentlyPlayed: recentlyPlayedAlbums,
|
||||
newlyAdded: newlyAddedAlbums,
|
||||
playlists: playlistItems,
|
||||
rediscover: rediscoverAlbums,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load music sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const music = createMusicStore();
|
||||
|
||||
export const musicHeroItems = derived(music, $m => $m.heroItems);
|
||||
export const isMusicLoading = derived(music, $m => $m.isLoading);
|
||||
@@ -0,0 +1,176 @@
|
||||
// TV library landing page data store.
|
||||
// Powers the focused TV landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/** A single "by genre" row: the genre name plus the series in it. */
|
||||
export interface GenreRow {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
interface TvState {
|
||||
// Episodes the user can resume (continue watching).
|
||||
continueWatching: MediaItem[];
|
||||
// Next unwatched episode per in-progress series.
|
||||
nextUp: MediaItem[];
|
||||
// Recently added items in the TV library (series/seasons/episodes).
|
||||
recentlyAdded: MediaItem[];
|
||||
// One slider per genre (top genres by show count).
|
||||
genreRows: GenreRow[];
|
||||
// Mix used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
// How many genre sliders to show, and how many genres to probe to find them.
|
||||
const MAX_GENRE_ROWS = 8;
|
||||
const MAX_GENRES_PROBED = 20;
|
||||
|
||||
function createTvStore() {
|
||||
const initialState: TvState = {
|
||||
continueWatching: [],
|
||||
nextUp: [],
|
||||
recentlyAdded: [],
|
||||
genreRows: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<TvState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation. Prefer in-progress episodes (most personal),
|
||||
* then fall back to next-up, then recently added. De-duplicates by id and
|
||||
* prefers items that carry backdrop/primary artwork for a good banner.
|
||||
*/
|
||||
function buildHero(
|
||||
continueWatching: MediaItem[],
|
||||
nextUp: MediaItem[],
|
||||
recentlyAdded: MediaItem[]
|
||||
): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
|
||||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
|
||||
!!i.primaryImageTag;
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
|
||||
for (const item of pool) {
|
||||
if (result.length >= 6) break;
|
||||
if (hasArt(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, nextUp, latest] = await Promise.all([
|
||||
repo.getResumeItems(libraryId, SECTION_LIMIT),
|
||||
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
|
||||
repo.getLatestItems(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
// Resume items are already video-only from the server, but keep episodes
|
||||
// (and the occasional movie that lives in a mixed library) defensively.
|
||||
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
|
||||
|
||||
const heroItems = buildHero(continueWatching, nextUp, latest);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
continueWatching,
|
||||
nextUp,
|
||||
recentlyAdded: latest,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
// Genre rows are secondary — load them after the main sections paint so
|
||||
// the page isn't blocked on N per-genre queries.
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load TV sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load TV sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one slider per genre, showing the top-rated series in each. We probe
|
||||
* a bounded set of genres in parallel, drop empty ones, then keep the genres
|
||||
* with the most shows (so niche/near-empty genres don't crowd the page).
|
||||
*/
|
||||
async function loadGenreRows(libraryId: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(libraryId);
|
||||
if (genres.length === 0) return;
|
||||
|
||||
const probed = genres.slice(0, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(
|
||||
probed.map(async (genre): Promise<GenreRow> => {
|
||||
try {
|
||||
const result = await repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Series"],
|
||||
genres: [genre.name],
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
});
|
||||
return { id: genre.id, name: genre.name, items: result.items };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load TV genre rows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const tv = createTvStore();
|
||||
|
||||
export const tvHeroItems = derived(tv, $t => $t.heroItems);
|
||||
export const isTvLoading = derived(tv, $t => $t.isLoading);
|
||||
Reference in New Issue
Block a user