rewritten loading categories and items

This commit is contained in:
Radek Davidek 2026-03-04 16:55:08 +01:00
parent 9bae442352
commit 0c5d5e73da
6 changed files with 517 additions and 142 deletions

21
pom.xml
View File

@ -42,6 +42,27 @@
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version> <version>3.14.0</version>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>cz.kamma.xtreamplayer.XtreamPlayerApplication</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>org.codehaus.mojo</groupId> <groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId> <artifactId>exec-maven-plugin</artifactId>

View File

@ -267,7 +267,7 @@ final class LibraryRepository {
} }
} }
List<LiveStreamRow> listLiveStreams(String categoryId, String search) { List<LiveStreamRow> listLiveStreams(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT stream_id, name, category_id, epg_channel_id FROM live_streams WHERE 1=1"); "SELECT stream_id, name, category_id, epg_channel_id FROM live_streams WHERE 1=1");
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
@ -280,6 +280,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
} }
sql.append(" ORDER BY LOWER(name), name"); 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<LiveStreamRow> rows = new ArrayList<>(); List<LiveStreamRow> rows = new ArrayList<>();
try (Connection connection = openConnection(); try (Connection connection = openConnection();
@ -301,7 +307,7 @@ final class LibraryRepository {
} }
} }
List<VodStreamRow> listVodStreams(String categoryId, String search) { List<VodStreamRow> listVodStreams(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1"); "SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1");
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
@ -314,6 +320,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
} }
sql.append(" ORDER BY LOWER(name), name"); 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<VodStreamRow> rows = new ArrayList<>(); List<VodStreamRow> rows = new ArrayList<>();
try (Connection connection = openConnection(); try (Connection connection = openConnection();
@ -335,7 +347,7 @@ final class LibraryRepository {
} }
} }
List<SeriesItemRow> listSeriesItems(String categoryId, String search) { List<SeriesItemRow> listSeriesItems(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT series_id, name, category_id FROM series_items WHERE 1=1"); "SELECT series_id, name, category_id FROM series_items WHERE 1=1");
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
@ -348,6 +360,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
} }
sql.append(" ORDER BY LOWER(name), name"); 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<SeriesItemRow> rows = new ArrayList<>(); List<SeriesItemRow> rows = new ArrayList<>();
try (Connection connection = openConnection(); try (Connection connection = openConnection();
@ -408,14 +426,42 @@ final class LibraryRepository {
} }
} }
List<FavoriteRow> listFavorites() { List<FavoriteRow> 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<FavoriteRow> rows = new ArrayList<>(); List<FavoriteRow> 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<Object> 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(); try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(""" PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) {
SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at args.add(limit);
FROM favorites args.add(offset);
ORDER BY created_at DESC, favorite_key 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()) { try (ResultSet resultSet = preparedStatement.executeQuery()) {
while (resultSet.next()) { while (resultSet.next()) {
rows.add(new FavoriteRow( 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<String> 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) { void upsertFavorite(FavoriteRow row) {
try (Connection connection = openConnection(); try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(""" PreparedStatement preparedStatement = connection.prepareStatement("""

View File

@ -144,18 +144,22 @@ final class XtreamLibraryService {
return repository.listCategories(type); 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); String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
return switch (normalizedType) { return switch (normalizedType) {
case "live" -> repository.listLiveStreams(categoryId, search); case "live" -> repository.listLiveStreams(categoryId, search, limit, offset);
case "vod" -> repository.listVodStreams(categoryId, search); case "vod" -> repository.listVodStreams(categoryId, search, limit, offset);
case "series" -> repository.listSeriesItems(categoryId, search); case "series" -> repository.listSeriesItems(categoryId, search, limit, offset);
default -> throw new IllegalArgumentException("Unsupported type: " + type); default -> throw new IllegalArgumentException("Unsupported type: " + type);
}; };
} }
List<LibraryRepository.FavoriteRow> listFavorites() { List<LibraryRepository.FavoriteRow> listFavorites(String search, int limit, int offset) {
return repository.listFavorites(); return repository.listFavorites(search, limit, offset);
}
int countFavorites(String search) {
return repository.countFavorites(search);
} }
LibraryRepository.FavoriteRow saveFavorite( LibraryRepository.FavoriteRow saveFavorite(

View File

@ -472,9 +472,17 @@ public final class XtreamPlayerApplication {
String type = query.getOrDefault("type", ""); String type = query.getOrDefault("type", "");
String categoryId = query.getOrDefault("category_id", ""); String categoryId = query.getOrDefault("category_id", "");
String search = query.getOrDefault("search", ""); 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<String, Object> out = new LinkedHashMap<>(); Map<String, Object> 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); writeJsonObject(exchange, 200, out);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
writeJson(exchange, 400, errorJson(exception.getMessage())); writeJson(exchange, 400, errorJson(exception.getMessage()));
@ -546,8 +554,23 @@ public final class XtreamPlayerApplication {
try { try {
if ("GET".equalsIgnoreCase(method)) { 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<String, Object> out = new LinkedHashMap<>(); Map<String, Object> 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); writeJsonObject(exchange, 200, out);
return; return;
} }
@ -609,7 +632,7 @@ public final class XtreamPlayerApplication {
if ("/".equals(path) || "/index.html".equals(path)) { if ("/".equals(path) || "/index.html".equals(path)) {
resourcePath = "/web/index.html"; resourcePath = "/web/index.html";
} else if (path.startsWith("/assets/")) { } else if (path.startsWith("/assets/")) {
resourcePath = "/web" + path; resourcePath = "/web" + normalizeAssetPath(path);
} else { } else {
resourcePath = "/web/index.html"; resourcePath = "/web/index.html";
} }
@ -627,6 +650,9 @@ public final class XtreamPlayerApplication {
LOGGER.debug("Serving static resource={}", resourcePath); LOGGER.debug("Serving static resource={}", resourcePath);
byte[] body = inputStream.readAllBytes(); byte[] body = inputStream.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", contentType(resourcePath)); 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.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body); exchange.getResponseBody().write(body);
} finally { } 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) { private static String configToJson(XtreamConfig config) {
return "{" return "{"
+ "\"serverUrl\":\"" + jsonEscape(config.serverUrl()) + "\"," + "\"serverUrl\":\"" + jsonEscape(config.serverUrl()) + "\","
@ -1156,6 +1193,19 @@ public final class XtreamPlayerApplication {
return "application/octet-stream"; 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) { private static String urlDecode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8); return URLDecoder.decode(value, StandardCharsets.UTF_8);
} }

View File

@ -17,6 +17,7 @@
expandedSeasonBySeries: {}, expandedSeasonBySeries: {},
customStreams: [], customStreams: [],
favorites: [], favorites: [],
favoriteKeys: new Set(),
expandedFavoriteSeriesById: {}, expandedFavoriteSeriesById: {},
favoriteSeriesEpisodesById: {}, favoriteSeriesEpisodesById: {},
expandedSeasonByFavoriteSeries: {}, expandedSeasonByFavoriteSeries: {},
@ -27,7 +28,14 @@
expandedFavoriteSeriesCategoryById: {}, expandedFavoriteSeriesCategoryById: {},
favoriteSeriesCategoryItemsById: {}, favoriteSeriesCategoryItemsById: {},
currentLiveEpgStreamId: null, 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"; const customStorageKey = "xtream_custom_streams_v1";
@ -72,6 +80,9 @@
customList: document.getElementById("custom-list"), customList: document.getElementById("custom-list"),
favoritesSearch: document.getElementById("favorites-search"), favoritesSearch: document.getElementById("favorites-search"),
favoritesList: document.getElementById("favorites-list"), 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"), playerTitle: document.getElementById("player-title"),
player: document.getElementById("player"), player: document.getElementById("player"),
@ -105,12 +116,12 @@
bindPlayerActionButtons(); bindPlayerActionButtons();
loadCustomStreams(); loadCustomStreams();
await loadFavorites();
renderCustomStreams(); renderCustomStreams();
renderFavorites(); renderFavorites();
renderStreamInfo(); renderStreamInfo();
updateLiveCategoryFavoriteButton(); updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton(); updateVodCategoryFavoriteButton();
switchTab("settings");
await loadConfig(); await loadConfig();
} }
@ -184,20 +195,35 @@
el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName)); el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName));
if (tabName === "favorites") { if (tabName === "favorites") {
renderFavorites(); loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
} }
if (!state.config?.configured || !state.libraryStatus?.ready) { if (!state.config?.configured || !state.libraryStatus?.ready) {
return; return;
} }
if (tabName === "live" && state.liveStreams.length === 0) { releaseInactiveTabItems(tabName);
loadLiveData().catch(showError); if (tabName === "live") {
if (state.liveCategories.length === 0) {
loadLiveData().catch(showError);
} else {
loadLiveStreams().catch(showError);
}
} }
if (tabName === "vod" && state.vodStreams.length === 0) { if (tabName === "vod") {
loadVodData().catch(showError); if (state.vodCategories.length === 0) {
loadVodData().catch(showError);
} else {
loadVodStreams().catch(showError);
}
} }
if (tabName === "series" && state.seriesItems.length === 0) { if (tabName === "series") {
loadSeriesData().catch(showError); if (state.seriesCategories.length === 0) {
loadSeriesData().catch(showError);
} else {
loadSeriesList().catch(showError);
}
} }
} }
@ -217,7 +243,7 @@
await refreshLibraryStatus(); await refreshLibraryStatus();
if (state.libraryStatus?.ready) { if (state.libraryStatus?.ready) {
await loadAllLibraryData(); renderAllFromState();
setSettingsMessage("Settings saved. Using local H2 data.", "ok"); setSettingsMessage("Settings saved. Using local H2 data.", "ok");
} else { } else {
renderAllFromState(); renderAllFromState();
@ -255,7 +281,7 @@
loadLiveStreams().catch(showError); loadLiveStreams().catch(showError);
updateLiveCategoryFavoriteButton(); updateLiveCategoryFavoriteButton();
}); });
el.liveSearch.addEventListener("input", renderLiveStreams); el.liveSearch.addEventListener("input", scheduleLiveSearch);
el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError)); el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError));
el.liveFavoriteCategory.addEventListener("click", () => { el.liveFavoriteCategory.addEventListener("click", () => {
const favorite = selectedLiveCategoryFavorite(); const favorite = selectedLiveCategoryFavorite();
@ -273,7 +299,7 @@
loadVodStreams().catch(showError); loadVodStreams().catch(showError);
updateVodCategoryFavoriteButton(); updateVodCategoryFavoriteButton();
}); });
el.vodSearch.addEventListener("input", renderVodStreams); el.vodSearch.addEventListener("input", scheduleVodSearch);
el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError)); el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError));
el.vodFavoriteCategory.addEventListener("click", () => { el.vodFavoriteCategory.addEventListener("click", () => {
const favorite = selectedVodCategoryFavorite(); const favorite = selectedVodCategoryFavorite();
@ -290,7 +316,7 @@
loadSeriesList().catch(showError); loadSeriesList().catch(showError);
updateSeriesCategoryFavoriteButton(); updateSeriesCategoryFavoriteButton();
}); });
el.seriesSearch.addEventListener("input", renderSeriesList); el.seriesSearch.addEventListener("input", scheduleSeriesSearch);
el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError)); el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError));
el.seriesFavoriteCategory.addEventListener("click", () => { el.seriesFavoriteCategory.addEventListener("click", () => {
const favorite = selectedSeriesCategoryFavorite(); const favorite = selectedSeriesCategoryFavorite();
@ -327,7 +353,26 @@
} }
function bindFavoritesTab() { 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() { async function loadConfig() {
@ -342,7 +387,7 @@
await refreshLibraryStatus(); await refreshLibraryStatus();
if (state.libraryStatus?.ready) { if (state.libraryStatus?.ready) {
await loadAllLibraryData(); renderAllFromState();
} else { } else {
clearLibraryState(); clearLibraryState();
renderAllFromState(); renderAllFromState();
@ -393,7 +438,7 @@
await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"}); await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"});
} }
await refreshLibraryStatus(); await refreshLibraryStatus();
await loadAllLibraryData(); renderAllFromState();
updateProgress(100, `Done. Sources saved in H2 (${new Date().toLocaleString("en-US")}).`); updateProgress(100, `Done. Sources saved in H2 (${new Date().toLocaleString("en-US")}).`);
setSettingsMessage( setSettingsMessage(
`Sources were loaded and saved to the local H2 database. ${formatSourceCounts(state.libraryStatus?.counts)}`, `Sources were loaded and saved to the local H2 database. ${formatSourceCounts(state.libraryStatus?.counts)}`,
@ -417,10 +462,6 @@
+ `Series: ${seriesCategories} categories / ${seriesItems} series.`; + `Series: ${seriesCategories} categories / ${seriesItems} series.`;
} }
async function loadAllLibraryData() {
await Promise.all([loadLiveData(), loadVodData(), loadSeriesData()]);
}
function refreshConfigUi() { function refreshConfigUi() {
const config = state.config || {}; const config = state.config || {};
el.serverUrl.value = config.serverUrl || ""; el.serverUrl.value = config.serverUrl || "";
@ -451,6 +492,65 @@
state.favoriteSeriesCategoryItemsById = {}; 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() { async function loadLiveData() {
ensureLibraryReady(); ensureLibraryReady();
const categoriesPayload = await apiJson("/api/library/categories?type=live"); const categoriesPayload = await apiJson("/api/library/categories?type=live");
@ -462,10 +562,22 @@
async function loadLiveStreams() { async function loadLiveStreams() {
ensureLibraryReady(); ensureLibraryReady();
const query = new URLSearchParams({type: "live"}); const categoryId = String(el.liveCategory.value || "").trim();
if (el.liveCategory.value) { const search = String(el.liveSearch.value || "").trim();
query.set("category_id", el.liveCategory.value); 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()}`); const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.liveStreams = sanitizeLiveStreams(payload.items); state.liveStreams = sanitizeLiveStreams(payload.items);
renderLiveStreams(); renderLiveStreams();
@ -500,15 +612,11 @@
el.liveFavoriteCategory.setAttribute("aria-label", label); el.liveFavoriteCategory.setAttribute("aria-label", label);
} }
function renderLiveStreams() { function renderLiveStreams(emptyMessage = "No live stream found.") {
const search = el.liveSearch.value.trim().toLowerCase(); const filtered = state.liveStreams;
const filtered = state.liveStreams.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
if (filtered.length === 0) { if (filtered.length === 0) {
el.liveList.innerHTML = `<li class="card muted">No live stream found.</li>`; el.liveList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return; return;
} }
@ -539,7 +647,7 @@
).catch(showError); ).catch(showError);
}); });
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { 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); el.liveList.appendChild(li);
}); });
@ -585,24 +693,32 @@
async function loadVodStreams() { async function loadVodStreams() {
ensureLibraryReady(); ensureLibraryReady();
const query = new URLSearchParams({type: "vod"}); const categoryId = String(el.vodCategory.value || "").trim();
if (el.vodCategory.value) { const search = String(el.vodSearch.value || "").trim();
query.set("category_id", el.vodCategory.value); 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()}`); const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.vodStreams = sanitizeVodStreams(payload.items); state.vodStreams = sanitizeVodStreams(payload.items);
renderVodStreams(); renderVodStreams();
} }
function renderVodStreams() { function renderVodStreams(emptyMessage = "No VOD stream found.") {
const search = el.vodSearch.value.trim().toLowerCase(); const filtered = state.vodStreams;
const filtered = state.vodStreams.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
if (filtered.length === 0) { if (filtered.length === 0) {
el.vodList.innerHTML = `<li class="card muted">No VOD stream found.</li>`; el.vodList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return; return;
} }
@ -627,7 +743,7 @@
.catch(showError); .catch(showError);
}); });
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { 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); el.vodList.appendChild(li);
}); });
@ -673,10 +789,24 @@
async function loadSeriesList() { async function loadSeriesList() {
ensureLibraryReady(); ensureLibraryReady();
const query = new URLSearchParams({type: "series"}); const categoryId = String(el.seriesCategory.value || "").trim();
if (el.seriesCategory.value) { const search = String(el.seriesSearch.value || "").trim();
query.set("category_id", el.seriesCategory.value); 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()}`); const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.seriesItems = sanitizeSeriesItems(payload.items); state.seriesItems = sanitizeSeriesItems(payload.items);
if (state.expandedSeriesId if (state.expandedSeriesId
@ -687,15 +817,11 @@
renderSeriesList(); renderSeriesList();
} }
function renderSeriesList() { function renderSeriesList(emptyMessage = "No series found.") {
const search = el.seriesSearch.value.trim().toLowerCase(); const filtered = state.seriesItems;
const filtered = state.seriesItems.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
if (filtered.length === 0) { if (filtered.length === 0) {
el.seriesList.innerHTML = `<li class="card muted">No series found.</li>`; el.seriesList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return; return;
} }
@ -719,7 +845,7 @@
toggleSeriesEpisodes(item).catch(showError); toggleSeriesEpisodes(item).catch(showError);
}); });
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { 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); el.seriesList.appendChild(li);
@ -778,7 +904,7 @@
}).catch(showError); }).catch(showError);
}); });
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(episodeFavorite).then(() => renderSeriesList()).catch(showError); toggleFavorite(episodeFavorite).then(() => loadSeriesList()).catch(showError);
}); });
seasonList.appendChild(row); seasonList.appendChild(row);
}); });
@ -860,22 +986,81 @@
localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams)); localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams));
} }
async function loadFavorites() { async function loadFavorites(searchRaw = "") {
const payload = await apiJson("/api/favorites"); const search = String(searchRaw || "").trim();
state.favorites = sanitizeFavorites(payload.items); 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) { 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) { async function toggleFavorite(favorite) {
if (!favorite || !favorite.key) { if (!favorite || !favorite.key) {
return; return;
} }
if (isFavorite(favorite.key)) { const deleted = await deleteFavoriteByKey(favorite.key);
await deleteFavoriteByKey(favorite.key); if (!deleted) {
} else {
const payload = { const payload = {
...favorite, ...favorite,
createdAt: Number(favorite?.createdAt || Date.now()) createdAt: Number(favorite?.createdAt || Date.now())
@ -891,13 +1076,66 @@
} }
state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key); state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key);
state.favorites.unshift(savedItem); state.favorites.unshift(savedItem);
state.favoriteKeys.add(savedItem.key);
} }
renderFavorites(); await reloadFavoritesCurrentPage();
updateLiveCategoryFavoriteButton(); updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton(); updateVodCategoryFavoriteButton();
updateSeriesCategoryFavoriteButton(); 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() { function renderFavorites() {
const search = el.favoritesSearch.value.trim().toLowerCase(); const search = el.favoritesSearch.value.trim().toLowerCase();
const matchesText = (value) => String(value || "").toLowerCase().includes(search); const matchesText = (value) => String(value || "").toLowerCase().includes(search);
@ -929,6 +1167,11 @@
} }
return false; 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) { if (filtered.length === 0) {
el.favoritesList.innerHTML = `<li class="card muted">No favorites yet.</li>`; el.favoritesList.innerHTML = `<li class="card muted">No favorites yet.</li>`;
@ -941,6 +1184,9 @@
const favoriteTone = favoriteToneClass(favorite); const favoriteTone = favoriteToneClass(favorite);
const favoriteBadge = favoriteBadgeLabel(favorite); const favoriteBadge = favoriteBadgeLabel(favorite);
li.className = `stream-item favorite-item ${favoriteTone}`; 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 isSeriesItem = favorite?.mode === "series_item";
const isLiveCategory = favorite?.mode === "live_category"; const isLiveCategory = favorite?.mode === "live_category";
const isVodCategory = favorite?.mode === "vod_category"; const isVodCategory = favorite?.mode === "vod_category";
@ -957,99 +1203,52 @@
li.innerHTML = isSeriesItem li.innerHTML = isSeriesItem
? ` ? `
<div> <div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series">${isSeriesItemExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button> <button type="button" class="stream-title stream-link" data-action="toggle-favorite-series" data-key="${esc(favorite.key)}">${isSeriesItemExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div> <div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div> </div>
<div class="stream-actions"> <div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button> <button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div> </div>
` `
: isLiveCategory : isLiveCategory
? ` ? `
<div> <div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-live-category">${isLiveCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button> <button type="button" class="stream-title stream-link" data-action="toggle-favorite-live-category" data-key="${esc(favorite.key)}">${isLiveCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div> <div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div> </div>
<div class="stream-actions"> <div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button> <button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div> </div>
` `
: isVodCategory : isVodCategory
? ` ? `
<div> <div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-vod-category">${isVodCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button> <button type="button" class="stream-title stream-link" data-action="toggle-favorite-vod-category" data-key="${esc(favorite.key)}">${isVodCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div> <div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div> </div>
<div class="stream-actions"> <div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button> <button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div> </div>
` `
: isSeriesCategory : isSeriesCategory
? ` ? `
<div> <div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series-category">${isSeriesCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button> <button type="button" class="stream-title stream-link" data-action="toggle-favorite-series-category" data-key="${esc(favorite.key)}">${isSeriesCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div> <div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div> </div>
<div class="stream-actions"> <div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button> <button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div> </div>
` `
: ` : `
<div> <div>
<button type="button" class="stream-title stream-link" data-action="open-favorite">${esc(favorite.title || "Untitled")}</button> <button type="button" class="stream-title stream-link" data-action="open-favorite" data-key="${esc(favorite.key)}">${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div> <div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div> </div>
<div class="stream-actions"> <div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button> <button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div> </div>
`; `;
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); el.favoritesList.appendChild(li);
if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) { if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) {
@ -1754,13 +1953,15 @@
async function deleteFavoriteByKey(keyRaw) { async function deleteFavoriteByKey(keyRaw) {
const key = String(keyRaw || "").trim(); const key = String(keyRaw || "").trim();
if (!key) { 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.favorites = state.favorites.filter((item) => item?.key !== key);
state.favoriteKeys.delete(key);
updateLiveCategoryFavoriteButton(); updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton(); updateVodCategoryFavoriteButton();
updateSeriesCategoryFavoriteButton(); updateSeriesCategoryFavoriteButton();
return Boolean(response?.deleted);
} }
function renderCustomStreams() { function renderCustomStreams() {
@ -2513,7 +2714,20 @@
} }
async function apiJson(url, options = {}) { 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(); const text = await response.text();
let parsed; let parsed;
try { try {

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xtream Player</title> <title>Xtream Player</title>
<link rel="stylesheet" href="/assets/style.css"> <link rel="stylesheet" href="/assets/style.20260304b.css">
</head> </head>
<body> <body>
<div class="bg-glow"></div> <div class="bg-glow"></div>
@ -148,6 +148,13 @@
Search Search
<input id="favorites-search" type="search" placeholder="Filter favorites"> <input id="favorites-search" type="search" placeholder="Filter favorites">
</label> </label>
<div class="card controls">
<div class="actions">
<button id="favorites-prev" type="button">Previous</button>
<button id="favorites-next" type="button">Next</button>
</div>
<div id="favorites-page-info" class="muted">Page 1</div>
</div>
<ul id="favorites-list" class="stream-list"></ul> <ul id="favorites-list" class="stream-list"></ul>
</article> </article>
</section> </section>
@ -198,6 +205,6 @@
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
<script src="/assets/app.js"></script> <script src="/assets/app.20260304b.js"></script>
</body> </body>
</html> </html>