diff --git a/pom.xml b/pom.xml index f9c81b9..c83b67f 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,27 @@ maven-compiler-plugin 3.14.0 + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + + + cz.kamma.xtreamplayer.XtreamPlayerApplication + + + + + + org.codehaus.mojo exec-maven-plugin diff --git a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java index f30dc30..ae94f3b 100644 --- a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java +++ b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java @@ -267,7 +267,7 @@ final class LibraryRepository { } } - List listLiveStreams(String categoryId, String search) { + List listLiveStreams(String categoryId, String search, Integer limit, Integer offset) { StringBuilder sql = new StringBuilder( "SELECT stream_id, name, category_id, epg_channel_id FROM live_streams WHERE 1=1"); List args = new ArrayList<>(); @@ -280,6 +280,12 @@ final class LibraryRepository { args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); } sql.append(" ORDER BY LOWER(name), name"); + if (limit != null && limit > 0) { + sql.append(" LIMIT ").append(limit); + if (offset != null && offset > 0) { + sql.append(" OFFSET ").append(offset); + } + } List rows = new ArrayList<>(); try (Connection connection = openConnection(); @@ -301,7 +307,7 @@ final class LibraryRepository { } } - List listVodStreams(String categoryId, String search) { + List listVodStreams(String categoryId, String search, Integer limit, Integer offset) { StringBuilder sql = new StringBuilder( "SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1"); List args = new ArrayList<>(); @@ -314,6 +320,12 @@ final class LibraryRepository { args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); } sql.append(" ORDER BY LOWER(name), name"); + if (limit != null && limit > 0) { + sql.append(" LIMIT ").append(limit); + if (offset != null && offset > 0) { + sql.append(" OFFSET ").append(offset); + } + } List rows = new ArrayList<>(); try (Connection connection = openConnection(); @@ -335,7 +347,7 @@ final class LibraryRepository { } } - List listSeriesItems(String categoryId, String search) { + List listSeriesItems(String categoryId, String search, Integer limit, Integer offset) { StringBuilder sql = new StringBuilder( "SELECT series_id, name, category_id FROM series_items WHERE 1=1"); List args = new ArrayList<>(); @@ -348,6 +360,12 @@ final class LibraryRepository { args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); } sql.append(" ORDER BY LOWER(name), name"); + if (limit != null && limit > 0) { + sql.append(" LIMIT ").append(limit); + if (offset != null && offset > 0) { + sql.append(" OFFSET ").append(offset); + } + } List rows = new ArrayList<>(); try (Connection connection = openConnection(); @@ -408,14 +426,42 @@ final class LibraryRepository { } } - List listFavorites() { + List listFavorites(String searchRaw, int limitRaw, int offsetRaw) { + String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT); + int limit = Math.max(1, limitRaw); + int offset = Math.max(0, offsetRaw); List rows = new ArrayList<>(); + StringBuilder sql = new StringBuilder(""" + SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at + FROM favorites + """); + List args = new ArrayList<>(); + if (!search.isBlank()) { + sql.append(""" + WHERE LOWER(title) LIKE ? + OR LOWER(mode) LIKE ? + OR LOWER(url) LIKE ? + OR LOWER(favorite_key) LIKE ? + """); + String value = "%" + search + "%"; + args.add(value); + args.add(value); + args.add(value); + args.add(value); + } + sql.append(" ORDER BY created_at DESC, favorite_key LIMIT ? OFFSET ?"); try (Connection connection = openConnection(); - PreparedStatement preparedStatement = connection.prepareStatement(""" - SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at - FROM favorites - ORDER BY created_at DESC, favorite_key - """)) { + PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) { + args.add(limit); + args.add(offset); + for (int i = 0; i < args.size(); i++) { + Object arg = args.get(i); + if (arg instanceof Integer intArg) { + preparedStatement.setInt(i + 1, intArg); + } else { + preparedStatement.setString(i + 1, String.valueOf(arg)); + } + } try (ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { rows.add(new FavoriteRow( @@ -439,6 +485,39 @@ final class LibraryRepository { } } + int countFavorites(String searchRaw) { + String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT); + StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM favorites"); + List args = new ArrayList<>(); + if (!search.isBlank()) { + sql.append(""" + WHERE LOWER(title) LIKE ? + OR LOWER(mode) LIKE ? + OR LOWER(url) LIKE ? + OR LOWER(favorite_key) LIKE ? + """); + String value = "%" + search + "%"; + args.add(value); + args.add(value); + args.add(value); + args.add(value); + } + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) { + for (int i = 0; i < args.size(); i++) { + preparedStatement.setString(i + 1, args.get(i)); + } + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getInt(1); + } + return 0; + } + } catch (SQLException exception) { + throw new IllegalStateException("Unable to count favorites.", exception); + } + } + void upsertFavorite(FavoriteRow row) { try (Connection connection = openConnection(); PreparedStatement preparedStatement = connection.prepareStatement(""" diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java index 2350c6f..d82a08a 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java @@ -144,18 +144,22 @@ final class XtreamLibraryService { return repository.listCategories(type); } - List listItems(String type, String categoryId, String search) { + List listItems(String type, String categoryId, String search, Integer limit, Integer offset) { String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT); return switch (normalizedType) { - case "live" -> repository.listLiveStreams(categoryId, search); - case "vod" -> repository.listVodStreams(categoryId, search); - case "series" -> repository.listSeriesItems(categoryId, search); + case "live" -> repository.listLiveStreams(categoryId, search, limit, offset); + case "vod" -> repository.listVodStreams(categoryId, search, limit, offset); + case "series" -> repository.listSeriesItems(categoryId, search, limit, offset); default -> throw new IllegalArgumentException("Unsupported type: " + type); }; } - List listFavorites() { - return repository.listFavorites(); + List listFavorites(String search, int limit, int offset) { + return repository.listFavorites(search, limit, offset); + } + + int countFavorites(String search) { + return repository.countFavorites(search); } LibraryRepository.FavoriteRow saveFavorite( diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java index 50a797c..f85ddae 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -472,9 +472,17 @@ public final class XtreamPlayerApplication { String type = query.getOrDefault("type", ""); String categoryId = query.getOrDefault("category_id", ""); String search = query.getOrDefault("search", ""); + Integer limit = query.containsKey("limit") ? parseIntOrDefault(query.get("limit"), 0) : null; + Integer offset = query.containsKey("offset") ? parseIntOrDefault(query.get("offset"), 0) : null; + if (limit != null && limit <= 0) { + limit = null; + } + if (offset != null && offset < 0) { + offset = 0; + } Map out = new LinkedHashMap<>(); - out.put("items", libraryService.listItems(type, categoryId, search)); + out.put("items", libraryService.listItems(type, categoryId, search, limit, offset)); writeJsonObject(exchange, 200, out); } catch (IllegalArgumentException exception) { writeJson(exchange, 400, errorJson(exception.getMessage())); @@ -546,8 +554,23 @@ public final class XtreamPlayerApplication { try { if ("GET".equalsIgnoreCase(method)) { + String search = query.getOrDefault("search", ""); + int limit = parseIntOrDefault(query.get("limit"), 50); + int offset = parseIntOrDefault(query.get("offset"), 0); + if (limit < 1) { + limit = 1; + } + if (limit > 200) { + limit = 200; + } + if (offset < 0) { + offset = 0; + } Map out = new LinkedHashMap<>(); - out.put("items", libraryService.listFavorites()); + out.put("items", libraryService.listFavorites(search, limit, offset)); + out.put("total", libraryService.countFavorites(search)); + out.put("limit", limit); + out.put("offset", offset); writeJsonObject(exchange, 200, out); return; } @@ -609,7 +632,7 @@ public final class XtreamPlayerApplication { if ("/".equals(path) || "/index.html".equals(path)) { resourcePath = "/web/index.html"; } else if (path.startsWith("/assets/")) { - resourcePath = "/web" + path; + resourcePath = "/web" + normalizeAssetPath(path); } else { resourcePath = "/web/index.html"; } @@ -627,6 +650,9 @@ public final class XtreamPlayerApplication { LOGGER.debug("Serving static resource={}", resourcePath); byte[] body = inputStream.readAllBytes(); exchange.getResponseHeaders().set("Content-Type", contentType(resourcePath)); + exchange.getResponseHeaders().set("Cache-Control", "no-store, no-cache, must-revalidate"); + exchange.getResponseHeaders().set("Pragma", "no-cache"); + exchange.getResponseHeaders().set("Expires", "0"); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); } finally { @@ -1099,6 +1125,17 @@ public final class XtreamPlayerApplication { } } + private static int parseIntOrDefault(String raw, int defaultValue) { + if (raw == null || raw.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + private static String configToJson(XtreamConfig config) { return "{" + "\"serverUrl\":\"" + jsonEscape(config.serverUrl()) + "\"," @@ -1156,6 +1193,19 @@ public final class XtreamPlayerApplication { return "application/octet-stream"; } + private static String normalizeAssetPath(String path) { + if (path == null || path.isBlank()) { + return "/assets/app.js"; + } + if (path.matches("^/assets/app\\.[^.]+\\.js$")) { + return "/assets/app.js"; + } + if (path.matches("^/assets/style\\.[^.]+\\.css$")) { + return "/assets/style.css"; + } + return path; + } + private static String urlDecode(String value) { return URLDecoder.decode(value, StandardCharsets.UTF_8); } diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js index d7e7aa5..37012c0 100644 --- a/src/main/resources/web/assets/app.js +++ b/src/main/resources/web/assets/app.js @@ -17,6 +17,7 @@ expandedSeasonBySeries: {}, customStreams: [], favorites: [], + favoriteKeys: new Set(), expandedFavoriteSeriesById: {}, favoriteSeriesEpisodesById: {}, expandedSeasonByFavoriteSeries: {}, @@ -27,7 +28,14 @@ expandedFavoriteSeriesCategoryById: {}, favoriteSeriesCategoryItemsById: {}, currentLiveEpgStreamId: null, - currentStreamInfo: null + currentStreamInfo: null, + liveSearchTimer: null, + vodSearchTimer: null, + seriesSearchTimer: null, + favoritesSearchTimer: null, + favoritesLimit: 50, + favoritesOffset: 0, + favoritesTotal: 0 }; const customStorageKey = "xtream_custom_streams_v1"; @@ -72,6 +80,9 @@ customList: document.getElementById("custom-list"), favoritesSearch: document.getElementById("favorites-search"), favoritesList: document.getElementById("favorites-list"), + favoritesPrev: document.getElementById("favorites-prev"), + favoritesNext: document.getElementById("favorites-next"), + favoritesPageInfo: document.getElementById("favorites-page-info"), playerTitle: document.getElementById("player-title"), player: document.getElementById("player"), @@ -105,12 +116,12 @@ bindPlayerActionButtons(); loadCustomStreams(); - await loadFavorites(); renderCustomStreams(); renderFavorites(); renderStreamInfo(); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); + switchTab("settings"); await loadConfig(); } @@ -184,20 +195,35 @@ el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName)); if (tabName === "favorites") { - renderFavorites(); + loadFavorites(String(el.favoritesSearch.value || "").trim()) + .then(() => renderFavorites()) + .catch(showError); } if (!state.config?.configured || !state.libraryStatus?.ready) { return; } - if (tabName === "live" && state.liveStreams.length === 0) { - loadLiveData().catch(showError); + releaseInactiveTabItems(tabName); + if (tabName === "live") { + if (state.liveCategories.length === 0) { + loadLiveData().catch(showError); + } else { + loadLiveStreams().catch(showError); + } } - if (tabName === "vod" && state.vodStreams.length === 0) { - loadVodData().catch(showError); + if (tabName === "vod") { + if (state.vodCategories.length === 0) { + loadVodData().catch(showError); + } else { + loadVodStreams().catch(showError); + } } - if (tabName === "series" && state.seriesItems.length === 0) { - loadSeriesData().catch(showError); + if (tabName === "series") { + if (state.seriesCategories.length === 0) { + loadSeriesData().catch(showError); + } else { + loadSeriesList().catch(showError); + } } } @@ -217,7 +243,7 @@ await refreshLibraryStatus(); if (state.libraryStatus?.ready) { - await loadAllLibraryData(); + renderAllFromState(); setSettingsMessage("Settings saved. Using local H2 data.", "ok"); } else { renderAllFromState(); @@ -255,7 +281,7 @@ loadLiveStreams().catch(showError); updateLiveCategoryFavoriteButton(); }); - el.liveSearch.addEventListener("input", renderLiveStreams); + el.liveSearch.addEventListener("input", scheduleLiveSearch); el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError)); el.liveFavoriteCategory.addEventListener("click", () => { const favorite = selectedLiveCategoryFavorite(); @@ -273,7 +299,7 @@ loadVodStreams().catch(showError); updateVodCategoryFavoriteButton(); }); - el.vodSearch.addEventListener("input", renderVodStreams); + el.vodSearch.addEventListener("input", scheduleVodSearch); el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError)); el.vodFavoriteCategory.addEventListener("click", () => { const favorite = selectedVodCategoryFavorite(); @@ -290,7 +316,7 @@ loadSeriesList().catch(showError); updateSeriesCategoryFavoriteButton(); }); - el.seriesSearch.addEventListener("input", renderSeriesList); + el.seriesSearch.addEventListener("input", scheduleSeriesSearch); el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError)); el.seriesFavoriteCategory.addEventListener("click", () => { const favorite = selectedSeriesCategoryFavorite(); @@ -327,7 +353,26 @@ } function bindFavoritesTab() { - el.favoritesSearch.addEventListener("input", renderFavorites); + el.favoritesSearch.addEventListener("input", scheduleFavoritesSearch); + el.favoritesPrev.addEventListener("click", () => { + if (state.favoritesOffset <= 0) { + return; + } + state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit); + loadFavorites(String(el.favoritesSearch.value || "").trim()) + .then(() => renderFavorites()) + .catch(showError); + }); + el.favoritesNext.addEventListener("click", () => { + if (state.favoritesOffset + state.favoritesLimit >= state.favoritesTotal) { + return; + } + state.favoritesOffset += state.favoritesLimit; + loadFavorites(String(el.favoritesSearch.value || "").trim()) + .then(() => renderFavorites()) + .catch(showError); + }); + el.favoritesList.addEventListener("click", onFavoritesListClick); } async function loadConfig() { @@ -342,7 +387,7 @@ await refreshLibraryStatus(); if (state.libraryStatus?.ready) { - await loadAllLibraryData(); + renderAllFromState(); } else { clearLibraryState(); renderAllFromState(); @@ -393,7 +438,7 @@ await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"}); } await refreshLibraryStatus(); - await loadAllLibraryData(); + renderAllFromState(); updateProgress(100, `Done. Sources saved in H2 (${new Date().toLocaleString("en-US")}).`); setSettingsMessage( `Sources were loaded and saved to the local H2 database. ${formatSourceCounts(state.libraryStatus?.counts)}`, @@ -417,10 +462,6 @@ + `Series: ${seriesCategories} categories / ${seriesItems} series.`; } - async function loadAllLibraryData() { - await Promise.all([loadLiveData(), loadVodData(), loadSeriesData()]); - } - function refreshConfigUi() { const config = state.config || {}; el.serverUrl.value = config.serverUrl || ""; @@ -451,6 +492,65 @@ state.favoriteSeriesCategoryItemsById = {}; } + function releaseInactiveTabItems(activeTab) { + if (activeTab !== "live") { + state.liveStreams = []; + } + if (activeTab !== "vod") { + state.vodStreams = []; + } + if (activeTab !== "series") { + state.seriesItems = []; + state.expandedSeriesId = null; + state.seriesEpisodesById = {}; + state.expandedSeasonBySeries = {}; + } + } + + function activeTabName() { + const active = el.tabs.querySelector("button[data-tab].active"); + return String(active?.dataset?.tab || "settings"); + } + + function scheduleLiveSearch() { + if (state.liveSearchTimer) { + clearTimeout(state.liveSearchTimer); + } + state.liveSearchTimer = setTimeout(() => { + loadLiveStreams().catch(showError); + }, 250); + } + + function scheduleVodSearch() { + if (state.vodSearchTimer) { + clearTimeout(state.vodSearchTimer); + } + state.vodSearchTimer = setTimeout(() => { + loadVodStreams().catch(showError); + }, 250); + } + + function scheduleSeriesSearch() { + if (state.seriesSearchTimer) { + clearTimeout(state.seriesSearchTimer); + } + state.seriesSearchTimer = setTimeout(() => { + loadSeriesList().catch(showError); + }, 250); + } + + function scheduleFavoritesSearch() { + if (state.favoritesSearchTimer) { + clearTimeout(state.favoritesSearchTimer); + } + state.favoritesSearchTimer = setTimeout(() => { + state.favoritesOffset = 0; + loadFavorites(String(el.favoritesSearch.value || "").trim()) + .then(() => renderFavorites()) + .catch(showError); + }, 250); + } + async function loadLiveData() { ensureLibraryReady(); const categoriesPayload = await apiJson("/api/library/categories?type=live"); @@ -462,10 +562,22 @@ async function loadLiveStreams() { ensureLibraryReady(); - const query = new URLSearchParams({type: "live"}); - if (el.liveCategory.value) { - query.set("category_id", el.liveCategory.value); + const categoryId = String(el.liveCategory.value || "").trim(); + const search = String(el.liveSearch.value || "").trim(); + if (!categoryId && search.length < 2) { + state.liveStreams = []; + renderLiveStreams("Select Live category or type at least 2 characters."); + return; } + const query = new URLSearchParams({type: "live"}); + if (categoryId) { + query.set("category_id", categoryId); + } + if (search) { + query.set("search", search); + } + query.set("limit", "300"); + query.set("offset", "0"); const payload = await apiJson(`/api/library/items?${query.toString()}`); state.liveStreams = sanitizeLiveStreams(payload.items); renderLiveStreams(); @@ -500,15 +612,11 @@ el.liveFavoriteCategory.setAttribute("aria-label", label); } - function renderLiveStreams() { - const search = el.liveSearch.value.trim().toLowerCase(); - const filtered = state.liveStreams.filter((item) => { - const name = String(item.name || "").toLowerCase(); - return !search || name.includes(search); - }); + function renderLiveStreams(emptyMessage = "No live stream found.") { + const filtered = state.liveStreams; if (filtered.length === 0) { - el.liveList.innerHTML = `
  • No live stream found.
  • `; + el.liveList.innerHTML = `
  • ${esc(emptyMessage)}
  • `; return; } @@ -539,7 +647,7 @@ ).catch(showError); }); li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { - toggleFavorite(favorite).then(() => renderLiveStreams()).catch(showError); + toggleFavorite(favorite).then(() => loadLiveStreams()).catch(showError); }); el.liveList.appendChild(li); }); @@ -585,24 +693,32 @@ async function loadVodStreams() { ensureLibraryReady(); - const query = new URLSearchParams({type: "vod"}); - if (el.vodCategory.value) { - query.set("category_id", el.vodCategory.value); + const categoryId = String(el.vodCategory.value || "").trim(); + const search = String(el.vodSearch.value || "").trim(); + if (!categoryId && search.length < 2) { + state.vodStreams = []; + renderVodStreams("Select VOD category or type at least 2 characters."); + return; } + const query = new URLSearchParams({type: "vod"}); + if (categoryId) { + query.set("category_id", categoryId); + } + if (search) { + query.set("search", search); + } + query.set("limit", "300"); + query.set("offset", "0"); const payload = await apiJson(`/api/library/items?${query.toString()}`); state.vodStreams = sanitizeVodStreams(payload.items); renderVodStreams(); } - function renderVodStreams() { - const search = el.vodSearch.value.trim().toLowerCase(); - const filtered = state.vodStreams.filter((item) => { - const name = String(item.name || "").toLowerCase(); - return !search || name.includes(search); - }); + function renderVodStreams(emptyMessage = "No VOD stream found.") { + const filtered = state.vodStreams; if (filtered.length === 0) { - el.vodList.innerHTML = `
  • No VOD stream found.
  • `; + el.vodList.innerHTML = `
  • ${esc(emptyMessage)}
  • `; return; } @@ -627,7 +743,7 @@ .catch(showError); }); li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { - toggleFavorite(favorite).then(() => renderVodStreams()).catch(showError); + toggleFavorite(favorite).then(() => loadVodStreams()).catch(showError); }); el.vodList.appendChild(li); }); @@ -673,10 +789,24 @@ async function loadSeriesList() { ensureLibraryReady(); - const query = new URLSearchParams({type: "series"}); - if (el.seriesCategory.value) { - query.set("category_id", el.seriesCategory.value); + const categoryId = String(el.seriesCategory.value || "").trim(); + const search = String(el.seriesSearch.value || "").trim(); + if (!categoryId && search.length < 2) { + state.seriesItems = []; + state.expandedSeriesId = null; + state.expandedSeasonBySeries = {}; + renderSeriesList("Select Series category or type at least 2 characters."); + return; } + const query = new URLSearchParams({type: "series"}); + if (categoryId) { + query.set("category_id", categoryId); + } + if (search) { + query.set("search", search); + } + query.set("limit", "300"); + query.set("offset", "0"); const payload = await apiJson(`/api/library/items?${query.toString()}`); state.seriesItems = sanitizeSeriesItems(payload.items); if (state.expandedSeriesId @@ -687,15 +817,11 @@ renderSeriesList(); } - function renderSeriesList() { - const search = el.seriesSearch.value.trim().toLowerCase(); - const filtered = state.seriesItems.filter((item) => { - const name = String(item.name || "").toLowerCase(); - return !search || name.includes(search); - }); + function renderSeriesList(emptyMessage = "No series found.") { + const filtered = state.seriesItems; if (filtered.length === 0) { - el.seriesList.innerHTML = `
  • No series found.
  • `; + el.seriesList.innerHTML = `
  • ${esc(emptyMessage)}
  • `; return; } @@ -719,7 +845,7 @@ toggleSeriesEpisodes(item).catch(showError); }); li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { - toggleFavorite(favorite).then(() => renderSeriesList()).catch(showError); + toggleFavorite(favorite).then(() => loadSeriesList()).catch(showError); }); el.seriesList.appendChild(li); @@ -778,7 +904,7 @@ }).catch(showError); }); row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { - toggleFavorite(episodeFavorite).then(() => renderSeriesList()).catch(showError); + toggleFavorite(episodeFavorite).then(() => loadSeriesList()).catch(showError); }); seasonList.appendChild(row); }); @@ -860,22 +986,81 @@ localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams)); } - async function loadFavorites() { - const payload = await apiJson("/api/favorites"); - state.favorites = sanitizeFavorites(payload.items); + async function loadFavorites(searchRaw = "") { + const search = String(searchRaw || "").trim(); + const query = new URLSearchParams(); + if (search) { + query.set("search", search); + } + query.set("limit", String(state.favoritesLimit)); + query.set("offset", String(state.favoritesOffset)); + const path = query.toString() ? `/api/favorites?${query.toString()}` : "/api/favorites"; + const payload = await apiJson(path); + const list = sanitizeFavorites(payload.items); + state.favorites = list; + list.forEach((item) => { + const key = String(item?.key || ""); + if (key) { + state.favoriteKeys.add(key); + } + }); + state.favoritesTotal = Number(payload?.total || 0); + state.favoritesLimit = Math.max(1, Number(payload?.limit || state.favoritesLimit || 50)); + state.favoritesOffset = Math.max(0, Number(payload?.offset || 0)); } function isFavorite(key) { - return state.favorites.some((item) => item?.key === key); + return state.favoriteKeys.has(String(key || "")); + } + + function findFavoriteByKey(keyRaw) { + const key = String(keyRaw || "").trim(); + if (!key) { + return null; + } + return state.favorites.find((item) => String(item?.key || "") === key) || null; + } + + function clearFavoriteCachesForItem(favorite) { + if (!favorite) { + return; + } + const mode = String(favorite?.mode || ""); + const id = String(favorite?.id || ""); + if (mode === "series_item") { + delete state.expandedFavoriteSeriesById[id]; + delete state.favoriteSeriesEpisodesById[id]; + delete state.expandedSeasonByFavoriteSeries[id]; + } + if (mode === "live_category") { + delete state.expandedFavoriteLiveCategoryById[id]; + delete state.favoriteLiveCategoryStreamsById[id]; + } + if (mode === "vod_category") { + delete state.expandedFavoriteVodCategoryById[id]; + delete state.favoriteVodCategoryStreamsById[id]; + } + if (mode === "series_category") { + delete state.expandedFavoriteSeriesCategoryById[id]; + delete state.favoriteSeriesCategoryItemsById[id]; + } + } + + async function reloadFavoritesCurrentPage() { + await loadFavorites(String(el.favoritesSearch.value || "").trim()); + if (state.favorites.length === 0 && state.favoritesOffset > 0) { + state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit); + await loadFavorites(String(el.favoritesSearch.value || "").trim()); + } + renderFavorites(); } async function toggleFavorite(favorite) { if (!favorite || !favorite.key) { return; } - if (isFavorite(favorite.key)) { - await deleteFavoriteByKey(favorite.key); - } else { + const deleted = await deleteFavoriteByKey(favorite.key); + if (!deleted) { const payload = { ...favorite, createdAt: Number(favorite?.createdAt || Date.now()) @@ -891,13 +1076,66 @@ } state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key); state.favorites.unshift(savedItem); + state.favoriteKeys.add(savedItem.key); } - renderFavorites(); + await reloadFavoritesCurrentPage(); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); updateSeriesCategoryFavoriteButton(); } + async function onFavoritesListClick(event) { + const actionNode = event.target.closest("[data-action]"); + if (!actionNode || !el.favoritesList.contains(actionNode)) { + return; + } + const action = String(actionNode.dataset.action || ""); + const key = String(actionNode.dataset.key || ""); + const favorite = findFavoriteByKey(key); + if (!favorite && action !== "toggle-favorite-season" && action !== "play-title" && action !== "toggle-favorite") { + return; + } + try { + if (action === "toggle-favorite-series" && favorite) { + await toggleFavoriteSeriesItem(favorite); + return; + } + if (action === "toggle-favorite-live-category" && favorite) { + await toggleFavoriteLiveCategory(favorite); + return; + } + if (action === "toggle-favorite-vod-category" && favorite) { + await toggleFavoriteVodCategory(favorite); + return; + } + if (action === "toggle-favorite-series-category" && favorite) { + await toggleFavoriteSeriesCategory(favorite); + return; + } + if (action === "open-favorite" && favorite) { + await openFavorite(favorite); + return; + } + if (action === "remove-favorite" && favorite) { + await deleteFavoriteByKey(favorite.key); + clearFavoriteCachesForItem(favorite); + await reloadFavoritesCurrentPage(); + if (activeTabName() === "live") { + await loadLiveStreams(); + } + if (activeTabName() === "vod") { + await loadVodStreams(); + } + if (activeTabName() === "series") { + await loadSeriesList(); + } + renderCustomStreams(); + } + } catch (error) { + showError(error); + } + } + function renderFavorites() { const search = el.favoritesSearch.value.trim().toLowerCase(); const matchesText = (value) => String(value || "").toLowerCase().includes(search); @@ -929,6 +1167,11 @@ } return false; }); + const from = state.favoritesTotal === 0 ? 0 : state.favoritesOffset + 1; + const to = Math.min(state.favoritesOffset + state.favoritesLimit, state.favoritesTotal); + el.favoritesPageInfo.textContent = `Showing ${from}-${to} of ${state.favoritesTotal}`; + el.favoritesPrev.disabled = state.favoritesOffset <= 0; + el.favoritesNext.disabled = state.favoritesOffset + state.favoritesLimit >= state.favoritesTotal; if (filtered.length === 0) { el.favoritesList.innerHTML = `
  • No favorites yet.
  • `; @@ -941,6 +1184,9 @@ const favoriteTone = favoriteToneClass(favorite); const favoriteBadge = favoriteBadgeLabel(favorite); li.className = `stream-item favorite-item ${favoriteTone}`; + li.dataset.favoriteKey = String(favorite?.key || ""); + li.dataset.favoriteMode = String(favorite?.mode || ""); + li.dataset.favoriteId = String(favorite?.id || ""); const isSeriesItem = favorite?.mode === "series_item"; const isLiveCategory = favorite?.mode === "live_category"; const isVodCategory = favorite?.mode === "vod_category"; @@ -957,99 +1203,52 @@ li.innerHTML = isSeriesItem ? `
    - +
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    - +
    ` : isLiveCategory ? `
    - +
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    - +
    ` : isVodCategory ? `
    - +
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    - +
    ` : isSeriesCategory ? `
    - +
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    - +
    ` : `
    - +
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    - +
    `; - if (isSeriesItem) { - li.querySelector("button[data-action='toggle-favorite-series']").addEventListener("click", () => { - toggleFavoriteSeriesItem(favorite).catch(showError); - }); - } else if (isLiveCategory) { - li.querySelector("button[data-action='toggle-favorite-live-category']").addEventListener("click", () => { - toggleFavoriteLiveCategory(favorite).catch(showError); - }); - } else if (isVodCategory) { - li.querySelector("button[data-action='toggle-favorite-vod-category']").addEventListener("click", () => { - toggleFavoriteVodCategory(favorite).catch(showError); - }); - } else if (isSeriesCategory) { - li.querySelector("button[data-action='toggle-favorite-series-category']").addEventListener("click", () => { - toggleFavoriteSeriesCategory(favorite).catch(showError); - }); - } else { - li.querySelector("button[data-action='open-favorite']").addEventListener("click", () => { - openFavorite(favorite).catch(showError); - }); - } - li.querySelector("button[data-action='remove-favorite']").addEventListener("click", () => { - deleteFavoriteByKey(favorite.key).then(() => { - if (favorite?.mode === "series_item") { - delete state.expandedFavoriteSeriesById[seriesId]; - delete state.favoriteSeriesEpisodesById[seriesId]; - delete state.expandedSeasonByFavoriteSeries[seriesId]; - } - if (favorite?.mode === "live_category") { - delete state.expandedFavoriteLiveCategoryById[liveCategoryId]; - delete state.favoriteLiveCategoryStreamsById[liveCategoryId]; - } - if (favorite?.mode === "vod_category") { - delete state.expandedFavoriteVodCategoryById[vodCategoryId]; - delete state.favoriteVodCategoryStreamsById[vodCategoryId]; - } - if (favorite?.mode === "series_category") { - delete state.expandedFavoriteSeriesCategoryById[seriesCategoryId]; - delete state.favoriteSeriesCategoryItemsById[seriesCategoryId]; - } - renderFavorites(); - renderLiveStreams(); - renderVodStreams(); - renderSeriesList(); - renderCustomStreams(); - }).catch(showError); - }); el.favoritesList.appendChild(li); if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) { @@ -1754,13 +1953,15 @@ async function deleteFavoriteByKey(keyRaw) { const key = String(keyRaw || "").trim(); if (!key) { - return; + return false; } - await apiJson(`/api/favorites?key=${encodeURIComponent(key)}`, {method: "DELETE"}); + const response = await apiJson(`/api/favorites?key=${encodeURIComponent(key)}`, {method: "DELETE"}); state.favorites = state.favorites.filter((item) => item?.key !== key); + state.favoriteKeys.delete(key); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); updateSeriesCategoryFavoriteButton(); + return Boolean(response?.deleted); } function renderCustomStreams() { @@ -2513,7 +2714,20 @@ } async function apiJson(url, options = {}) { - const response = await fetch(url, options); + const fetchOptions = {...options}; + if (!fetchOptions.cache) { + fetchOptions.cache = "no-store"; + } + const headers = new Headers(fetchOptions.headers || {}); + if (!headers.has("Cache-Control")) { + headers.set("Cache-Control", "no-cache"); + } + if (!headers.has("Pragma")) { + headers.set("Pragma", "no-cache"); + } + fetchOptions.headers = headers; + + const response = await fetch(url, fetchOptions); const text = await response.text(); let parsed; try { diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 42a57e9..c42e0e4 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -4,7 +4,7 @@ Xtream Player - +
    @@ -148,6 +148,13 @@ Search +
    +
    + + +
    +
    Page 1
    +
      @@ -198,6 +205,6 @@ - +