feat: new shadcn-style ui, code cleanup and better reliability
This commit is contained in:
parent
4027e37f26
commit
77b597e5d8
6 changed files with 1953 additions and 750 deletions
|
@ -1,180 +1,186 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Repiped</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Repiped</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
<link rel="stylesheet" href="plyr.min.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo" id="logo">
|
||||
<i class="fas fa-play logo-icon"></i>
|
||||
<h1>Repiped</h1>
|
||||
</div>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo" id="logo">
|
||||
<i class="fas fa-play logo-icon"></i>
|
||||
<h1>Repiped</h1>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search for videos..." />
|
||||
<button class="search-button" id="search-button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a href="https://git.eplg.services/obvtiger/repiped" target="_blank" class="github-button" title="View source code">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="page active" id="trending-page">
|
||||
<div id="trending-content">
|
||||
<div class="loading-container" id="trending-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div class="trending-grid" id="trending-grid" style="display: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="search-results-page">
|
||||
<div class="search-heading">
|
||||
<h2 class="search-query" id="search-query">Search Results</h2>
|
||||
<span class="search-count" id="search-count">0 results</span>
|
||||
</div>
|
||||
<div id="search-content">
|
||||
<div class="loading-container" id="search-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div class="search-grid" id="search-grid" style="display: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="channel-page">
|
||||
<div class="channel-header">
|
||||
<div class="channel-banner-container">
|
||||
<img class="channel-banner" id="channel-banner" src="" alt="Channel banner" />
|
||||
</div>
|
||||
<div class="channel-info">
|
||||
<div class="channel-avatar-container">
|
||||
<img class="channel-avatar" id="channel-avatar" src="" alt="Channel avatar" />
|
||||
</div>
|
||||
<div class="channel-details">
|
||||
<div class="channel-title-container">
|
||||
<h1 class="channel-title" id="channel-title"></h1>
|
||||
<span class="channel-verified" id="channel-verified" style="display: none"><i
|
||||
class="fas fa-check-circle"></i></span>
|
||||
</div>
|
||||
<div class="channel-stats">
|
||||
<span class="channel-subs" id="channel-subs"></span>
|
||||
</div>
|
||||
<div class="channel-description" id="channel-description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-content">
|
||||
<div class="loading-container" id="channel-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div class="channel-videos-grid" id="channel-videos-grid" style="display: none"></div>
|
||||
<div class="load-more-container" id="channel-load-more" style="display: none">
|
||||
<div class="loader" id="channel-load-more-spinner" style="display: none"></div>
|
||||
<button class="load-more-button" id="channel-load-more-button">
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="video-player-page">
|
||||
<div class="video-player-container">
|
||||
<div class="player-wrapper" id="player-wrapper">
|
||||
<div id="preparation-overlay" class="preparation-overlay">
|
||||
<div class="preparation-status" id="preparation-status">
|
||||
Preparing video...
|
||||
</div>
|
||||
<div class="preparation-progress-container">
|
||||
<div class="preparation-progress-bar" id="preparation-progress-bar"></div>
|
||||
</div>
|
||||
<button class="reload-button" id="reload-button" style="display: none">
|
||||
Reload Page
|
||||
<div class="search-container">
|
||||
<div class="search-form">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="search-input"
|
||||
placeholder="Search for videos..."
|
||||
/>
|
||||
<button class="search-button" id="search-button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-detail">
|
||||
<h1 class="video-detail-title" id="detail-title"></h1>
|
||||
<div class="video-detail-meta">
|
||||
<div class="video-detail-uploader">
|
||||
<img class="uploader-avatar-large" id="detail-avatar" src="" alt="Uploader avatar" />
|
||||
<div class="uploader-info">
|
||||
<span class="uploader-name" id="detail-uploader"></span>
|
||||
<span class="uploaded-date" id="detail-uploaded"></span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://git.eplg.services/obvtiger/repiped"
|
||||
target="_blank"
|
||||
class="github-button"
|
||||
title="View source code"
|
||||
>
|
||||
<i class="fas fa-code-branch"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="page active" id="trending-page">
|
||||
<div id="trending-content">
|
||||
<div class="loading-container" id="trending-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div
|
||||
class="trending-grid"
|
||||
id="trending-grid"
|
||||
style="display: none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="search-results-page">
|
||||
<div class="search-heading">
|
||||
<h2 class="search-query" id="search-query">Search Results</h2>
|
||||
<span class="search-count" id="search-count">0 results</span>
|
||||
</div>
|
||||
<div id="search-content">
|
||||
<div class="loading-container" id="search-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div class="search-grid" id="search-grid" style="display: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="channel-page">
|
||||
<div class="channel-header">
|
||||
<div class="channel-banner-container">
|
||||
<img
|
||||
class="channel-banner"
|
||||
id="channel-banner"
|
||||
src=""
|
||||
alt="Channel banner"
|
||||
/>
|
||||
</div>
|
||||
<div class="channel-info">
|
||||
<div class="channel-avatar-container">
|
||||
<img
|
||||
class="channel-avatar"
|
||||
id="channel-avatar"
|
||||
src=""
|
||||
alt="Channel avatar"
|
||||
/>
|
||||
</div>
|
||||
<div class="video-detail-stats">
|
||||
<div class="detail-stat">
|
||||
<div class="channel-details">
|
||||
<div class="channel-title-container">
|
||||
<h1 class="channel-title" id="channel-title"></h1>
|
||||
<span
|
||||
class="channel-verified"
|
||||
id="channel-verified"
|
||||
style="display: none"
|
||||
>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="channel-stats">
|
||||
<span class="channel-subs" id="channel-subs"></span>
|
||||
</div>
|
||||
<div class="channel-description" id="channel-description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-content">
|
||||
<div class="loading-container" id="channel-loader">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div
|
||||
class="channel-videos-grid"
|
||||
id="channel-videos-grid"
|
||||
style="display: none"
|
||||
></div>
|
||||
<div
|
||||
class="load-more-container"
|
||||
id="channel-load-more"
|
||||
style="display: none"
|
||||
>
|
||||
<div
|
||||
class="loader"
|
||||
id="channel-load-more-spinner"
|
||||
style="display: none"
|
||||
></div>
|
||||
<button class="load-more-button" id="channel-load-more-button">
|
||||
Load More Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="video-card-template">
|
||||
<div class="video-card animated-item">
|
||||
<div class="thumbnail-container">
|
||||
<img class="thumbnail" src="" alt="Video thumbnail" />
|
||||
<span class="duration"></span>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title"></h3>
|
||||
<div class="video-meta">
|
||||
<div class="uploader">
|
||||
<img class="uploader-avatar" src="" alt="Uploader avatar" />
|
||||
<span class="uploader-name"></span>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="stat views">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span id="detail-views"></span>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="view-count"></span>
|
||||
</span>
|
||||
<span class="stat uploaded">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span id="detail-duration"></span>
|
||||
</div>
|
||||
<span class="upload-time"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-description" id="detail-description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="related-videos">
|
||||
<h2 class="section-title">Watch More</h2>
|
||||
<div class="related-grid" id="related-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="video-card-template">
|
||||
<div class="video-card animated-item">
|
||||
<div class="thumbnail-container">
|
||||
<img class="thumbnail" src="" alt="Video thumbnail" />
|
||||
<span class="duration"></span>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title"></h3>
|
||||
<div class="video-meta">
|
||||
<div class="uploader">
|
||||
<img class="uploader-avatar" src="" alt="Uploader avatar" />
|
||||
<span class="uploader-name"></span>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="stat views">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span class="view-count"></span>
|
||||
</span>
|
||||
<span class="stat uploaded">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="upload-time"></span>
|
||||
</span>
|
||||
</div>
|
||||
<template id="skeleton-template">
|
||||
<div class="video-card animated-item">
|
||||
<div class="skeleton skeleton-video"></div>
|
||||
<div class="video-info">
|
||||
<div class="skeleton skeleton-title"></div>
|
||||
<div class="skeleton skeleton-uploader"></div>
|
||||
<div class="skeleton skeleton-stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="skeleton-template">
|
||||
<div class="video-card animated-item">
|
||||
<div class="skeleton skeleton-video"></div>
|
||||
<div class="video-info">
|
||||
<div class="skeleton skeleton-title"></div>
|
||||
<div class="skeleton skeleton-uploader"></div>
|
||||
<div class="skeleton skeleton-stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="plyr.min.js"></script>
|
||||
<script src="hls.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</template>
|
||||
|
||||
<script src="trending.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -13,6 +13,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
let activeChannelId = null;
|
||||
let channelNextPageData = null;
|
||||
let isLoadingMoreVideos = false;
|
||||
let currentVideoData = null;
|
||||
|
||||
const trendingPage = document.getElementById("trending-page");
|
||||
const searchResultsPage = document.getElementById("search-results-page");
|
||||
|
@ -161,28 +162,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
return videoElement;
|
||||
}
|
||||
|
||||
async function findVideoById(videoId) {
|
||||
if (currentVideoData && currentVideoData.url.includes(videoId)) {
|
||||
return currentVideoData;
|
||||
}
|
||||
|
||||
try {
|
||||
const trendingResponse = await fetch(trendingApiUrl);
|
||||
if (trendingResponse.ok) {
|
||||
const trendingVideos = await trendingResponse.json();
|
||||
const foundInTrending = trendingVideos.find((v) => v.url.includes(videoId));
|
||||
if (foundInTrending) {
|
||||
return foundInTrending;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSearchQuery) {
|
||||
const searchUrl = `${searchApiUrl}?q=${encodeURIComponent(lastSearchQuery)}&filter=all`;
|
||||
const searchResponse = await fetch(searchUrl);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
const searchResults = searchData.items ? searchData.items.filter((item) => item.type === "stream") : [];
|
||||
const foundInSearch = searchResults.find((v) => v.url.includes(videoId));
|
||||
if (foundInSearch) {
|
||||
return foundInSearch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error searching for video:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVideoDetails(videoId) {
|
||||
try {
|
||||
if (videoId === activeVideoId) {
|
||||
if (videoId === activeVideoId && currentVideoData) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeVideoId = videoId;
|
||||
|
||||
trendingLoader.style.display = "flex";
|
||||
|
||||
const response = await fetch(trendingApiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const videos = await response.json();
|
||||
const video = videos.find((v) => v.url.includes(videoId));
|
||||
const video = await findVideoById(videoId);
|
||||
|
||||
if (video) {
|
||||
currentVideoData = video;
|
||||
openVideoPlayer(video);
|
||||
} else {
|
||||
console.error("Video not found in trending");
|
||||
console.error("Video not found in any source");
|
||||
trendingLoader.style.display = "none";
|
||||
alert("Video not found. Showing trending videos instead.");
|
||||
goToTrendingPage();
|
||||
|
@ -197,6 +227,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
cleanupResources();
|
||||
|
||||
activeVideoId = null;
|
||||
currentVideoData = null;
|
||||
|
||||
activeChannelId = null;
|
||||
|
||||
|
@ -656,6 +687,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
function openVideoPlayer(video) {
|
||||
cleanupResources();
|
||||
|
||||
currentVideoData = video;
|
||||
|
||||
detailTitle.textContent = video.title;
|
||||
detailAvatar.src = video.uploaderAvatar;
|
||||
detailUploader.textContent = video.uploaderName;
|
||||
|
@ -887,7 +920,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
loadMoreButton.style.display = "none";
|
||||
loadMoreSpinner.style.display = "block";
|
||||
|
||||
const url = `https:
|
||||
const url = `https://pipedapi.wireway.ch/nextpage/channel/${activeChannelId}?nextpage=${encodeURIComponent(
|
||||
channelNextPageData
|
||||
)}`;
|
||||
console.log(`Fetching next page data from: ${url}`);
|
||||
|
|
1039
public/style.css
1039
public/style.css
File diff suppressed because it is too large
Load diff
644
public/trending.js
Normal file
644
public/trending.js
Normal file
|
@ -0,0 +1,644 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const trendingPage = document.getElementById("trending-page");
|
||||
const searchResultsPage = document.getElementById("search-results-page");
|
||||
const channelPage = document.getElementById("channel-page");
|
||||
const trendingGrid = document.getElementById("trending-grid");
|
||||
const trendingLoader = document.getElementById("trending-loader");
|
||||
const searchGrid = document.getElementById("search-grid");
|
||||
const searchLoader = document.getElementById("search-loader");
|
||||
const searchQueryText = document.getElementById("search-query");
|
||||
const searchCountText = document.getElementById("search-count");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const searchButton = document.getElementById("search-button");
|
||||
const logo = document.getElementById("logo");
|
||||
const channelVideosGrid = document.getElementById("channel-videos-grid");
|
||||
const channelLoader = document.getElementById("channel-loader");
|
||||
|
||||
const channelBanner = document.getElementById("channel-banner");
|
||||
const channelAvatar = document.getElementById("channel-avatar");
|
||||
const channelTitle = document.getElementById("channel-title");
|
||||
const channelVerified = document.getElementById("channel-verified");
|
||||
const channelSubs = document.getElementById("channel-subs");
|
||||
const channelDescription = document.getElementById("channel-description");
|
||||
|
||||
const videoCardTemplate = document.getElementById("video-card-template");
|
||||
const skeletonTemplate = document.getElementById("skeleton-template");
|
||||
|
||||
let lastSearchQuery = "";
|
||||
let activeChannelId = null;
|
||||
let channelNextPageData = null;
|
||||
let isLoadingMoreVideos = false;
|
||||
|
||||
const trendingApiUrl = "/api/trending";
|
||||
const searchApiUrl = "/api/search";
|
||||
|
||||
function formatViews(views) {
|
||||
if (views >= 1000000) {
|
||||
return `${(views / 1000000).toFixed(1)}M`;
|
||||
} else if (views >= 1000) {
|
||||
return `${(views / 1000).toFixed(1)}K`;
|
||||
} else {
|
||||
return views.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
} else {
|
||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSubscribers(count) {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M subscribers`;
|
||||
} else if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}K subscribers`;
|
||||
} else {
|
||||
return `${count} subscribers`;
|
||||
}
|
||||
}
|
||||
|
||||
function getHashParams() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = {};
|
||||
|
||||
hash.split("&").forEach((param) => {
|
||||
if (param) {
|
||||
const [key, value] = param.split("=");
|
||||
params[key] = decodeURIComponent(value);
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function updateHash(params = {}) {
|
||||
const hashParts = [];
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
hashParts.push(`${key}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (hashParts.length > 0) {
|
||||
history.pushState("", document.title, `#${hashParts.join("&")}`);
|
||||
} else {
|
||||
history.pushState("", document.title, window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
function switchPage(fromPageId, toPageId) {
|
||||
const fromPage = document.getElementById(fromPageId);
|
||||
const toPage = document.getElementById(toPageId);
|
||||
|
||||
if (!fromPage || !toPage) return;
|
||||
|
||||
fromPage.classList.add("zoom-out");
|
||||
|
||||
setTimeout(() => {
|
||||
fromPage.classList.remove("active");
|
||||
fromPage.classList.remove("zoom-out");
|
||||
|
||||
toPage.classList.add("zoom-in");
|
||||
toPage.classList.add("active");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
toPage.classList.remove("zoom-in");
|
||||
});
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function showSkeletons(container, count = 12) {
|
||||
container.innerHTML = "";
|
||||
for (let i = 0; i < count; i++) {
|
||||
const skeleton = skeletonTemplate.content.cloneNode(true);
|
||||
skeleton.firstElementChild.style.animationDelay = `${i * 0.05}s`;
|
||||
container.appendChild(skeleton);
|
||||
}
|
||||
container.style.display = "grid";
|
||||
|
||||
if (container === trendingGrid) {
|
||||
trendingLoader.style.display = "none";
|
||||
} else if (container === searchGrid) {
|
||||
searchLoader.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const observeElements = () => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.1,
|
||||
rootMargin: "0px 0px -50px 0px",
|
||||
}
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll(".animated-item:not(.visible)")
|
||||
.forEach((item) => {
|
||||
observer.observe(item);
|
||||
});
|
||||
};
|
||||
|
||||
function openVideoPlayer(video) {
|
||||
sessionStorage.setItem('currentVideo', JSON.stringify(video));
|
||||
sessionStorage.setItem('lastSearchQuery', lastSearchQuery);
|
||||
|
||||
const videoId = video.url.replace("/watch?v=", "");
|
||||
window.location.href = `video.html?v=${videoId}`;
|
||||
}
|
||||
|
||||
function createVideoCard(video, index) {
|
||||
if (video.type !== "stream" && video.type !== undefined) {
|
||||
console.log(
|
||||
`Skipping non-video item: ${video.type} - ${video.title || video.name}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const card = videoCardTemplate.content.cloneNode(true);
|
||||
const thumbnail = card.querySelector(".thumbnail");
|
||||
const duration = card.querySelector(".duration");
|
||||
const title = card.querySelector(".video-title");
|
||||
const uploaderAvatar = card.querySelector(".uploader-avatar");
|
||||
const uploaderName = card.querySelector(".uploader-name");
|
||||
const viewCount = card.querySelector(".view-count");
|
||||
const uploadTime = card.querySelector(".upload-time");
|
||||
const videoCard = card.querySelector(".video-card");
|
||||
const uploader = card.querySelector(".uploader");
|
||||
|
||||
try {
|
||||
thumbnail.src = video.thumbnail || "";
|
||||
thumbnail.alt = video.title || "Video thumbnail";
|
||||
|
||||
if (typeof video.duration === "number") {
|
||||
duration.textContent = formatDuration(video.duration);
|
||||
} else {
|
||||
duration.textContent = "0:00";
|
||||
}
|
||||
|
||||
title.textContent = video.title || "Untitled video";
|
||||
uploaderAvatar.src = video.uploaderAvatar || "";
|
||||
uploaderAvatar.alt = `${video.uploaderName || "Uploader"} avatar`;
|
||||
uploaderName.textContent = video.uploaderName || "Unknown uploader";
|
||||
viewCount.textContent = formatViews(video.views || 0);
|
||||
uploadTime.textContent = video.uploadedDate || "Unknown date";
|
||||
|
||||
videoCard.style.animationDelay = `${index * 0.05}s`;
|
||||
|
||||
videoCard.addEventListener("click", (e) => {
|
||||
if (
|
||||
e.target === uploaderAvatar ||
|
||||
e.target === uploaderName ||
|
||||
(e.target.parentElement && e.target.parentElement === uploader)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
if (video.uploaderUrl) {
|
||||
openChannelPage(video.uploaderUrl.replace("/channel/", ""));
|
||||
}
|
||||
} else {
|
||||
openVideoPlayer(video);
|
||||
}
|
||||
});
|
||||
|
||||
uploader.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (video.uploaderUrl) {
|
||||
openChannelPage(video.uploaderUrl.replace("/channel/", ""));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating video card:", error, video);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function fetchTrendingVideos() {
|
||||
try {
|
||||
trendingLoader.style.display = "flex";
|
||||
trendingGrid.style.display = "none";
|
||||
|
||||
const response = await fetch(trendingApiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const videos = await response.json();
|
||||
|
||||
sessionStorage.setItem('trendingVideos', JSON.stringify(videos));
|
||||
|
||||
showSkeletons(trendingGrid);
|
||||
|
||||
setTimeout(() => {
|
||||
trendingGrid.innerHTML = "";
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
const card = createVideoCard(video, index);
|
||||
if (card) {
|
||||
trendingGrid.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
observeElements();
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error("Error fetching trending videos:", error);
|
||||
|
||||
trendingLoader.style.display = "none";
|
||||
trendingGrid.style.display = "block";
|
||||
trendingGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||
<h3>Failed to load videos</h3>
|
||||
<p>Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
if (!query || query.trim() === "") return;
|
||||
|
||||
lastSearchQuery = query.trim();
|
||||
updateHash({ search: lastSearchQuery });
|
||||
|
||||
if (trendingPage.classList.contains("active")) {
|
||||
switchPage("trending-page", "search-results-page");
|
||||
} else if (channelPage.classList.contains("active")) {
|
||||
switchPage("channel-page", "search-results-page");
|
||||
}
|
||||
|
||||
searchQueryText.textContent = `"${lastSearchQuery}"`;
|
||||
searchCountText.textContent = "Searching...";
|
||||
|
||||
searchGrid.style.display = "none";
|
||||
searchLoader.style.display = "flex";
|
||||
|
||||
try {
|
||||
const url = `${searchApiUrl}?q=${encodeURIComponent(lastSearchQuery)}&filter=all`;
|
||||
console.log(`Fetching search results from: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok: ${response.status}`);
|
||||
}
|
||||
|
||||
const searchData = await response.json();
|
||||
|
||||
const results = searchData.items
|
||||
? searchData.items.filter((item) => item.type === "stream")
|
||||
: [];
|
||||
|
||||
sessionStorage.setItem('searchResults', JSON.stringify(results));
|
||||
|
||||
console.log(`Found ${results.length} video results out of ${searchData.items ? searchData.items.length : 0} total items`);
|
||||
|
||||
searchCountText.textContent = `${results.length} videos`;
|
||||
|
||||
if (results.length === 0) {
|
||||
searchLoader.style.display = "none";
|
||||
searchGrid.style.display = "block";
|
||||
searchGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-search fa-3x"></i>
|
||||
<h3>No videos found</h3>
|
||||
<p>No video results found for "${lastSearchQuery}"</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
showSkeletons(searchGrid, Math.min(12, results.length));
|
||||
|
||||
setTimeout(() => {
|
||||
searchGrid.innerHTML = "";
|
||||
|
||||
results.forEach((video, index) => {
|
||||
const card = createVideoCard(video, index);
|
||||
searchGrid.appendChild(card);
|
||||
});
|
||||
|
||||
observeElements();
|
||||
}, 300);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching search results:", error);
|
||||
searchLoader.style.display = "none";
|
||||
searchGrid.style.display = "block";
|
||||
searchGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||
<h3>Search failed</h3>
|
||||
<p>Error searching videos. Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function goToTrendingPage() {
|
||||
updateHash();
|
||||
|
||||
if (searchResultsPage.classList.contains("active")) {
|
||||
switchPage("search-results-page", "trending-page");
|
||||
} else if (channelPage.classList.contains("active")) {
|
||||
switchPage("channel-page", "trending-page");
|
||||
}
|
||||
|
||||
fetchTrendingVideos();
|
||||
}
|
||||
|
||||
async function fetchChannelData(channelId) {
|
||||
try {
|
||||
if (channelId === activeChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeChannelId = channelId;
|
||||
channelNextPageData = null;
|
||||
|
||||
updateHash({ channelId });
|
||||
|
||||
if (trendingPage.classList.contains("active")) {
|
||||
switchPage("trending-page", "channel-page");
|
||||
} else if (searchResultsPage.classList.contains("active")) {
|
||||
switchPage("search-results-page", "channel-page");
|
||||
}
|
||||
|
||||
channelLoader.style.display = "flex";
|
||||
channelVideosGrid.style.display = "none";
|
||||
document.getElementById("channel-load-more").style.display = "none";
|
||||
|
||||
channelTitle.textContent = "";
|
||||
channelSubs.textContent = "";
|
||||
channelDescription.textContent = "";
|
||||
channelAvatar.src = "";
|
||||
channelBanner.src = "";
|
||||
channelVerified.style.display = "none";
|
||||
|
||||
const url = `https://pipedapi.wireway.ch/channel/${channelId}`;
|
||||
console.log(`Fetching channel data from: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok: ${response.status}`);
|
||||
}
|
||||
|
||||
const channelData = await response.json();
|
||||
console.log("Channel data:", channelData);
|
||||
|
||||
if (!channelData || typeof channelData !== "object") {
|
||||
throw new Error("Invalid channel data received");
|
||||
}
|
||||
|
||||
channelNextPageData = channelData.nextpage || null;
|
||||
|
||||
channelTitle.textContent = channelData.name || "Unknown Channel";
|
||||
channelSubs.textContent = formatSubscribers(channelData.subscriberCount || 0);
|
||||
|
||||
if (channelData.description) {
|
||||
channelDescription.textContent = channelData.description.replace(/\\n/g, "\n");
|
||||
} else {
|
||||
channelDescription.textContent = "No description available";
|
||||
}
|
||||
|
||||
if (channelData.avatarUrl) {
|
||||
channelAvatar.src = channelData.avatarUrl;
|
||||
channelAvatar.style.display = "block";
|
||||
} else {
|
||||
channelAvatar.style.display = "none";
|
||||
}
|
||||
|
||||
if (channelData.bannerUrl) {
|
||||
channelBanner.src = channelData.bannerUrl;
|
||||
channelBanner.style.display = "block";
|
||||
} else {
|
||||
channelBanner.style.display = "none";
|
||||
}
|
||||
|
||||
channelVerified.style.display = channelData.verified ? "inline-block" : "none";
|
||||
|
||||
const relatedStreams = Array.isArray(channelData.relatedStreams) ? channelData.relatedStreams : [];
|
||||
|
||||
const videoCount = relatedStreams.length;
|
||||
showSkeletons(channelVideosGrid, Math.min(12, videoCount || 6));
|
||||
|
||||
setTimeout(() => {
|
||||
channelVideosGrid.innerHTML = "";
|
||||
|
||||
if (relatedStreams.length > 0) {
|
||||
relatedStreams.forEach((video, index) => {
|
||||
if (!video.uploaderName) {
|
||||
video.uploaderName = channelData.name || "Unknown Channel";
|
||||
}
|
||||
if (!video.uploaderUrl) {
|
||||
video.uploaderUrl = `/channel/${channelData.id}`;
|
||||
}
|
||||
if (!video.uploaderAvatar) {
|
||||
video.uploaderAvatar = channelData.avatarUrl || "";
|
||||
}
|
||||
|
||||
const card = createVideoCard(video, index);
|
||||
if (card) {
|
||||
channelVideosGrid.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
observeElements();
|
||||
|
||||
const loadMoreContainer = document.getElementById("channel-load-more");
|
||||
if (channelNextPageData) {
|
||||
loadMoreContainer.style.display = "flex";
|
||||
} else {
|
||||
loadMoreContainer.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
channelVideosGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-video fa-3x"></i>
|
||||
<h3>No videos available</h3>
|
||||
<p>This channel hasn't uploaded any videos yet.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
channelLoader.style.display = "none";
|
||||
channelVideosGrid.style.display = "grid";
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
console.error("Error fetching channel data:", error);
|
||||
channelLoader.style.display = "none";
|
||||
channelVideosGrid.style.display = "block";
|
||||
channelVideosGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||
<h3>Failed to load channel</h3>
|
||||
<p>Error loading channel. Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreChannelVideos() {
|
||||
if (!channelNextPageData || isLoadingMoreVideos) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingMoreVideos = true;
|
||||
|
||||
const loadMoreButton = document.getElementById("channel-load-more-button");
|
||||
const loadMoreSpinner = document.getElementById("channel-load-more-spinner");
|
||||
loadMoreButton.style.display = "none";
|
||||
loadMoreSpinner.style.display = "block";
|
||||
|
||||
const url = `https://pipedapi.wireway.ch/nextpage/channel/${activeChannelId}?nextpage=${encodeURIComponent(channelNextPageData)}`;
|
||||
console.log(`Fetching next page data from: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok: ${response.status}`);
|
||||
}
|
||||
|
||||
const nextPageData = await response.json();
|
||||
console.log("Next page data:", nextPageData);
|
||||
|
||||
if (!nextPageData || !Array.isArray(nextPageData.relatedStreams)) {
|
||||
throw new Error("Invalid next page data received");
|
||||
}
|
||||
|
||||
channelNextPageData = nextPageData.nextpage || null;
|
||||
|
||||
const relatedStreams = nextPageData.relatedStreams;
|
||||
const startIndex = document.querySelectorAll(".channel-videos-grid .video-card").length;
|
||||
|
||||
if (relatedStreams.length > 0) {
|
||||
relatedStreams.forEach((video, index) => {
|
||||
if (!video.uploaderName) {
|
||||
video.uploaderName = channelTitle.textContent || "Unknown Channel";
|
||||
}
|
||||
if (!video.uploaderUrl) {
|
||||
video.uploaderUrl = `/channel/${activeChannelId}`;
|
||||
}
|
||||
if (!video.uploaderAvatar) {
|
||||
video.uploaderAvatar = channelAvatar.src || "";
|
||||
}
|
||||
|
||||
const card = createVideoCard(video, startIndex + index);
|
||||
if (card) {
|
||||
channelVideosGrid.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
observeElements();
|
||||
}
|
||||
|
||||
if (channelNextPageData) {
|
||||
loadMoreButton.style.display = "block";
|
||||
} else {
|
||||
document.getElementById("channel-load-more").style.display = "none";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading more videos:", error);
|
||||
|
||||
const loadMoreContainer = document.getElementById("channel-load-more");
|
||||
loadMoreContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>Error loading more videos. Click to try again.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
loadMoreContainer.addEventListener('click', () => {
|
||||
loadMoreContainer.innerHTML = `
|
||||
<div class="loader" id="channel-load-more-spinner" style="display: none;"></div>
|
||||
<button class="load-more-button" id="channel-load-more-button">Load More Videos</button>
|
||||
`;
|
||||
document.getElementById("channel-load-more-button").addEventListener("click", loadMoreChannelVideos);
|
||||
});
|
||||
} finally {
|
||||
document.getElementById("channel-load-more-spinner").style.display = "none";
|
||||
isLoadingMoreVideos = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openChannelPage(channelId) {
|
||||
fetchChannelData(channelId);
|
||||
}
|
||||
|
||||
function checkInitialHash() {
|
||||
const params = getHashParams();
|
||||
if (params.search) {
|
||||
searchInput.value = params.search;
|
||||
performSearch(params.search);
|
||||
} else if (params.channelId) {
|
||||
fetchChannelData(params.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
window.addEventListener("scroll", function () {
|
||||
if (!channelPage.classList.contains("active") || !channelNextPageData || isLoadingMoreVideos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollPos = window.scrollY + window.innerHeight;
|
||||
const loadMoreContainer = document.getElementById("channel-load-more");
|
||||
const loadMorePosition = loadMoreContainer.offsetTop;
|
||||
|
||||
if (scrollPos > loadMorePosition - 300) {
|
||||
loadMoreChannelVideos();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("channel-load-more-button").addEventListener("click", loadMoreChannelVideos);
|
||||
}
|
||||
|
||||
logo.addEventListener("click", () => {
|
||||
goToTrendingPage();
|
||||
});
|
||||
|
||||
searchButton.addEventListener("click", () => {
|
||||
performSearch(searchInput.value);
|
||||
});
|
||||
|
||||
searchInput.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
performSearch(searchInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const params = getHashParams();
|
||||
if (params.search) {
|
||||
performSearch(params.search);
|
||||
} else if (params.channelId) {
|
||||
fetchChannelData(params.channelId);
|
||||
} else {
|
||||
goToTrendingPage();
|
||||
}
|
||||
});
|
||||
|
||||
fetchTrendingVideos();
|
||||
checkInitialHash();
|
||||
setupInfiniteScroll();
|
||||
});
|
152
public/video.html
Normal file
152
public/video.html
Normal file
|
@ -0,0 +1,152 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Repiped - Video Player</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="plyr.min.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo" id="logo">
|
||||
<i class="fas fa-play logo-icon"></i>
|
||||
<h1>Repiped</h1>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<div class="search-form">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="search-input"
|
||||
placeholder="Search for videos..."
|
||||
/>
|
||||
<button class="search-button" id="search-button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://git.eplg.services/obvtiger/repiped"
|
||||
target="_blank"
|
||||
class="github-button"
|
||||
title="View source code"
|
||||
>
|
||||
<i class="fas fa-code-branch"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="video-player-container">
|
||||
<div class="player-wrapper" id="player-wrapper">
|
||||
<div id="preparation-overlay" class="preparation-overlay">
|
||||
<div class="preparation-status" id="preparation-status">
|
||||
Preparing video...
|
||||
</div>
|
||||
<div class="preparation-progress-container">
|
||||
<div
|
||||
class="preparation-progress-bar"
|
||||
id="preparation-progress-bar"
|
||||
></div>
|
||||
</div>
|
||||
<button
|
||||
class="reload-button"
|
||||
id="reload-button"
|
||||
style="display: none"
|
||||
>
|
||||
<i class="fas fa-redo-alt" style="margin-right: 0.5rem"></i>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-detail">
|
||||
<h1 class="video-detail-title" id="detail-title">Loading...</h1>
|
||||
<div class="video-detail-meta">
|
||||
<div class="video-detail-uploader">
|
||||
<img
|
||||
class="uploader-avatar-large"
|
||||
id="detail-avatar"
|
||||
src=""
|
||||
alt="Uploader avatar"
|
||||
/>
|
||||
<div class="uploader-info">
|
||||
<span class="uploader-name" id="detail-uploader"
|
||||
>Loading...</span
|
||||
>
|
||||
<span class="uploaded-date" id="detail-uploaded"
|
||||
>Loading...</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-detail-stats">
|
||||
<div class="detail-stat">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span id="detail-views">Loading...</span>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span id="detail-duration">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-description" id="detail-description">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="related-videos">
|
||||
<h2 class="section-title">Related Videos</h2>
|
||||
<div class="related-grid" id="related-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="video-card-template">
|
||||
<div class="video-card animated-item">
|
||||
<div class="thumbnail-container">
|
||||
<img class="thumbnail" src="" alt="Video thumbnail" />
|
||||
<span class="duration"></span>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title"></h3>
|
||||
<div class="video-meta">
|
||||
<div class="uploader">
|
||||
<img class="uploader-avatar" src="" alt="Uploader avatar" />
|
||||
<span class="uploader-name"></span>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="stat views">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span class="view-count"></span>
|
||||
</span>
|
||||
<span class="stat uploaded">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="upload-time"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="plyr.min.js"></script>
|
||||
<script src="hls.min.js"></script>
|
||||
<script src="video.js"></script>
|
||||
</body>
|
||||
</html>
|
479
public/video.js
Normal file
479
public/video.js
Normal file
|
@ -0,0 +1,479 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const playerWrapper = document.getElementById("player-wrapper");
|
||||
const preparationOverlay = document.getElementById("preparation-overlay");
|
||||
const preparationStatus = document.getElementById("preparation-status");
|
||||
const preparationProgressBar = document.getElementById("preparation-progress-bar");
|
||||
const reloadButton = document.getElementById("reload-button");
|
||||
const relatedGrid = document.getElementById("related-grid");
|
||||
const logo = document.getElementById("logo");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const searchButton = document.getElementById("search-button");
|
||||
|
||||
const detailTitle = document.getElementById("detail-title");
|
||||
const detailAvatar = document.getElementById("detail-avatar");
|
||||
const detailUploader = document.getElementById("detail-uploader");
|
||||
const detailUploaded = document.getElementById("detail-uploaded");
|
||||
const detailViews = document.getElementById("detail-views");
|
||||
const detailDuration = document.getElementById("detail-duration");
|
||||
const detailDescription = document.getElementById("detail-description");
|
||||
|
||||
const videoCardTemplate = document.getElementById("video-card-template");
|
||||
|
||||
let activeWs = null;
|
||||
let hlsInstance = null;
|
||||
let player = null;
|
||||
let prepareInProgress = false;
|
||||
let currentVideo = null;
|
||||
|
||||
const trendingApiUrl = "/api/trending";
|
||||
const searchApiUrl = "/api/search";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const wsBaseUrl = wsProtocol + window.location.host + "/api/v1/prepare";
|
||||
|
||||
function formatViews(views) {
|
||||
if (views >= 1000000) {
|
||||
return `${(views / 1000000).toFixed(1)}M`;
|
||||
} else if (views >= 1000) {
|
||||
return `${(views / 1000).toFixed(1)}K`;
|
||||
} else {
|
||||
return views.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
} else {
|
||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoIdFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('v');
|
||||
}
|
||||
|
||||
function getVideoElement() {
|
||||
const existingVideo = playerWrapper.querySelector("video");
|
||||
if (existingVideo) {
|
||||
existingVideo.remove();
|
||||
}
|
||||
|
||||
const videoElement = document.createElement("video");
|
||||
videoElement.id = "video-player";
|
||||
videoElement.setAttribute("controls", "");
|
||||
videoElement.setAttribute("crossorigin", "");
|
||||
videoElement.setAttribute("playsinline", "");
|
||||
|
||||
playerWrapper.appendChild(videoElement);
|
||||
return videoElement;
|
||||
}
|
||||
|
||||
function cleanupResources() {
|
||||
if (activeWs && activeWs.readyState === WebSocket.OPEN) {
|
||||
activeWs.close();
|
||||
activeWs = null;
|
||||
}
|
||||
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy();
|
||||
hlsInstance = null;
|
||||
}
|
||||
|
||||
if (player) {
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
prepareInProgress = false;
|
||||
}
|
||||
|
||||
const observeElements = () => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.1,
|
||||
rootMargin: "0px 0px -50px 0px",
|
||||
}
|
||||
);
|
||||
|
||||
document.querySelectorAll(".animated-item:not(.visible)").forEach((item) => {
|
||||
observer.observe(item);
|
||||
});
|
||||
};
|
||||
|
||||
function openVideoPlayer(video) {
|
||||
sessionStorage.setItem('currentVideo', JSON.stringify(video));
|
||||
const videoId = video.url.replace("/watch?v=", "");
|
||||
window.location.href = `video.html?v=${videoId}`;
|
||||
}
|
||||
|
||||
function createVideoCard(video, index) {
|
||||
if (video.type !== "stream" && video.type !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const card = videoCardTemplate.content.cloneNode(true);
|
||||
const thumbnail = card.querySelector(".thumbnail");
|
||||
const duration = card.querySelector(".duration");
|
||||
const title = card.querySelector(".video-title");
|
||||
const uploaderAvatar = card.querySelector(".uploader-avatar");
|
||||
const uploaderName = card.querySelector(".uploader-name");
|
||||
const viewCount = card.querySelector(".view-count");
|
||||
const uploadTime = card.querySelector(".upload-time");
|
||||
const videoCard = card.querySelector(".video-card");
|
||||
|
||||
try {
|
||||
thumbnail.src = video.thumbnail || "";
|
||||
thumbnail.alt = video.title || "Video thumbnail";
|
||||
|
||||
if (typeof video.duration === "number") {
|
||||
duration.textContent = formatDuration(video.duration);
|
||||
} else {
|
||||
duration.textContent = "0:00";
|
||||
}
|
||||
|
||||
title.textContent = video.title || "Untitled video";
|
||||
uploaderAvatar.src = video.uploaderAvatar || "";
|
||||
uploaderAvatar.alt = `${video.uploaderName || "Uploader"} avatar`;
|
||||
uploaderName.textContent = video.uploaderName || "Unknown uploader";
|
||||
viewCount.textContent = formatViews(video.views || 0);
|
||||
uploadTime.textContent = video.uploadedDate || "Unknown date";
|
||||
|
||||
videoCard.style.animationDelay = `${index * 0.05}s`;
|
||||
|
||||
videoCard.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
openVideoPlayer(video);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating video card:", error, video);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function findVideoById(videoId) {
|
||||
const storedVideo = sessionStorage.getItem('currentVideo');
|
||||
if (storedVideo) {
|
||||
const video = JSON.parse(storedVideo);
|
||||
if (video.url.includes(videoId)) {
|
||||
return video;
|
||||
}
|
||||
}
|
||||
|
||||
const storedTrending = sessionStorage.getItem('trendingVideos');
|
||||
if (storedTrending) {
|
||||
const trendingVideos = JSON.parse(storedTrending);
|
||||
const foundInTrending = trendingVideos.find((v) => v.url.includes(videoId));
|
||||
if (foundInTrending) {
|
||||
return foundInTrending;
|
||||
}
|
||||
}
|
||||
|
||||
const storedSearch = sessionStorage.getItem('searchResults');
|
||||
if (storedSearch) {
|
||||
const searchResults = JSON.parse(storedSearch);
|
||||
const foundInSearch = searchResults.find((v) => v.url.includes(videoId));
|
||||
if (foundInSearch) {
|
||||
return foundInSearch;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Video not found in cache, fetching from trending API...');
|
||||
const response = await fetch(trendingApiUrl);
|
||||
if (response.ok) {
|
||||
const videos = await response.json();
|
||||
const foundVideo = videos.find((v) => v.url.includes(videoId));
|
||||
if (foundVideo) {
|
||||
return foundVideo;
|
||||
}
|
||||
}
|
||||
|
||||
const lastSearchQuery = sessionStorage.getItem('lastSearchQuery');
|
||||
if (lastSearchQuery) {
|
||||
console.log('Trying search API with last query...');
|
||||
const searchUrl = `${searchApiUrl}?q=${encodeURIComponent(lastSearchQuery)}&filter=all`;
|
||||
const searchResponse = await fetch(searchUrl);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
const searchResults = searchData.items ? searchData.items.filter((item) => item.type === "stream") : [];
|
||||
const foundInSearch = searchResults.find((v) => v.url.includes(videoId));
|
||||
if (foundInSearch) {
|
||||
return foundInSearch;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching video from API:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function initializePlayer(streamUrl) {
|
||||
const videoElement = getVideoElement();
|
||||
|
||||
if (player) {
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
player = new Plyr(videoElement, {
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"pip",
|
||||
"airplay",
|
||||
"fullscreen",
|
||||
],
|
||||
settings: ["captions", "quality", "speed"],
|
||||
tooltips: { controls: true, seek: true },
|
||||
keyboard: { focused: true, global: true },
|
||||
});
|
||||
|
||||
if (videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoElement.src = streamUrl;
|
||||
} else if (Hls.isSupported()) {
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy();
|
||||
}
|
||||
|
||||
hlsInstance = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
});
|
||||
|
||||
hlsInstance.loadSource(streamUrl);
|
||||
hlsInstance.attachMedia(videoElement);
|
||||
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
videoElement.play().catch((e) => {
|
||||
console.warn("Autoplay prevented:", e);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error("HLS is not supported in this browser");
|
||||
preparationStatus.textContent = "Your browser does not support HLS playback";
|
||||
}
|
||||
}
|
||||
|
||||
function prepareVideo(videoUrl) {
|
||||
if (prepareInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
prepareInProgress = true;
|
||||
preparationOverlay.style.display = "flex";
|
||||
preparationStatus.textContent = "Preparing video...";
|
||||
preparationProgressBar.style.width = "0%";
|
||||
reloadButton.style.display = "none";
|
||||
|
||||
const cleanVideoId = videoUrl.replace("/watch?v=", "");
|
||||
|
||||
if (activeWs && activeWs.readyState === WebSocket.OPEN) {
|
||||
activeWs.close();
|
||||
}
|
||||
|
||||
activeWs = new WebSocket(wsBaseUrl);
|
||||
|
||||
activeWs.onopen = () => {
|
||||
activeWs.send(JSON.stringify({ videoId: cleanVideoId }));
|
||||
};
|
||||
|
||||
activeWs.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.ok === "true") {
|
||||
preparationStatus.textContent = "Connection established...";
|
||||
} else if (data.preparing !== undefined) {
|
||||
const percentage = parseFloat(data.preparing);
|
||||
preparationProgressBar.style.width = `${percentage}%`;
|
||||
preparationStatus.textContent = `Preparing video... ${percentage.toFixed(0)}%`;
|
||||
} else if (data.done && data.streamUrl) {
|
||||
preparationStatus.textContent = "Video ready! Starting playback...";
|
||||
preparationProgressBar.style.width = "100%";
|
||||
|
||||
setTimeout(() => {
|
||||
preparationOverlay.style.display = "none";
|
||||
initializePlayer(data.streamUrl);
|
||||
prepareInProgress = false;
|
||||
}, 500);
|
||||
|
||||
activeWs.close();
|
||||
activeWs = null;
|
||||
} else {
|
||||
preparationStatus.textContent = "Video playback failed. Try reloading the page.";
|
||||
reloadButton.style.display = "block";
|
||||
prepareInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
activeWs.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
preparationStatus.textContent = "Error preparing video. Please try again.";
|
||||
reloadButton.style.display = "block";
|
||||
prepareInProgress = false;
|
||||
|
||||
setTimeout(() => {
|
||||
if (preparationOverlay.style.display !== "none") {
|
||||
preparationOverlay.style.display = "none";
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
activeWs.onclose = () => {
|
||||
console.log("WebSocket connection closed");
|
||||
if (activeWs === activeWs) {
|
||||
activeWs = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function displayVideoDetails(video) {
|
||||
document.title = `${video.title} - Repiped`;
|
||||
detailTitle.textContent = video.title;
|
||||
detailAvatar.src = video.uploaderAvatar;
|
||||
detailUploader.textContent = video.uploaderName;
|
||||
detailUploaded.textContent = video.uploadedDate;
|
||||
detailViews.textContent = formatViews(video.views);
|
||||
detailDuration.textContent = formatDuration(video.duration);
|
||||
detailDescription.textContent = video.shortDescription || "No description available";
|
||||
|
||||
const videoDetailUploader = document.querySelector(".video-detail-uploader");
|
||||
if (videoDetailUploader) {
|
||||
videoDetailUploader.style.cursor = "pointer";
|
||||
videoDetailUploader.addEventListener("click", () => {
|
||||
if (video.uploaderUrl) {
|
||||
const channelId = video.uploaderUrl.replace("/channel/", "");
|
||||
window.location.href = `index.html#channelId=${channelId}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRelatedVideos(currentVideo) {
|
||||
try {
|
||||
let videos = [];
|
||||
const storedTrending = sessionStorage.getItem('trendingVideos');
|
||||
|
||||
if (storedTrending) {
|
||||
videos = JSON.parse(storedTrending);
|
||||
} else {
|
||||
const response = await fetch(trendingApiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
videos = await response.json();
|
||||
}
|
||||
|
||||
videos = videos
|
||||
.filter((video) => video.url !== currentVideo.url)
|
||||
.slice(0, 8);
|
||||
|
||||
relatedGrid.innerHTML = "";
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
const card = createVideoCard(video, index);
|
||||
if (card) {
|
||||
relatedGrid.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
observeElements();
|
||||
} catch (error) {
|
||||
console.error("Error fetching related videos:", error);
|
||||
relatedGrid.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||
<h3>Failed to load related videos</h3>
|
||||
<p>Error loading related videos.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
if (!query || query.trim() === "") return;
|
||||
|
||||
window.location.href = `index.html#search=${encodeURIComponent(query.trim())}`;
|
||||
}
|
||||
|
||||
async function loadVideo() {
|
||||
const videoId = getVideoIdFromUrl();
|
||||
|
||||
if (!videoId) {
|
||||
alert("No video ID provided");
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Loading video: ${videoId}`);
|
||||
|
||||
try {
|
||||
const video = await findVideoById(videoId);
|
||||
|
||||
if (!video) {
|
||||
alert("Video not found. Redirecting to home page.");
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
|
||||
currentVideo = video;
|
||||
|
||||
sessionStorage.setItem('currentVideo', JSON.stringify(video));
|
||||
|
||||
displayVideoDetails(video);
|
||||
|
||||
prepareVideo(video.url);
|
||||
|
||||
fetchRelatedVideos(video);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading video:", error);
|
||||
alert("Error loading video. Redirecting to home page.");
|
||||
window.location.href = "index.html";
|
||||
}
|
||||
}
|
||||
|
||||
logo.addEventListener("click", () => {
|
||||
window.location.href = "index.html";
|
||||
});
|
||||
|
||||
searchButton.addEventListener("click", () => {
|
||||
performSearch(searchInput.value);
|
||||
});
|
||||
|
||||
searchInput.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
performSearch(searchInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
reloadButton.addEventListener("click", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
cleanupResources();
|
||||
});
|
||||
|
||||
loadVideo();
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue