forked from obvtiger/repiped
Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
c1a4025cbd | |||
77b597e5d8 | |||
4027e37f26 | |||
376982e031 | |||
132fc97a1a |
12 changed files with 1578 additions and 1249 deletions
BIN
deps/yt-dlp
(Stored with Git LFS)
vendored
BIN
deps/yt-dlp
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
deps/yt-dlp.exe
(Stored with Git LFS)
vendored
BIN
deps/yt-dlp.exe
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
deps/yt-dlp_linux_aarch64
(Stored with Git LFS)
vendored
BIN
deps/yt-dlp_linux_aarch64
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
deps/yt-dlp_linux_armv7l
(Stored with Git LFS)
vendored
BIN
deps/yt-dlp_linux_armv7l
(Stored with Git LFS)
vendored
Binary file not shown.
|
@ -1,30 +1,53 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<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>Repiped</title>
|
<title>Repiped</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
<link
|
||||||
<link rel="stylesheet" href="plyr.min.css" />
|
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" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<div class="header-content">
|
||||||
<div class="logo" id="logo">
|
<div class="logo" id="logo">
|
||||||
<i class="fas fa-play logo-icon"></i>
|
<i class="fas fa-play logo-icon"></i>
|
||||||
<h1>Repiped</h1>
|
<h1>Repiped</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text" class="search-input" id="search-input" placeholder="Search for videos..." />
|
<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">
|
<button class="search-button" id="search-button">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<a href="https://git.eplg.services/obvtiger/repiped" target="_blank" class="github-button" title="View source code">
|
</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>
|
<i class="fas fa-code-branch"></i>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -33,7 +56,11 @@
|
||||||
<div class="loading-container" id="trending-loader">
|
<div class="loading-container" id="trending-loader">
|
||||||
<div class="loader"></div>
|
<div class="loader"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trending-grid" id="trending-grid" style="display: none"></div>
|
<div
|
||||||
|
class="trending-grid"
|
||||||
|
id="trending-grid"
|
||||||
|
style="display: none"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -53,17 +80,32 @@
|
||||||
<div class="page" id="channel-page">
|
<div class="page" id="channel-page">
|
||||||
<div class="channel-header">
|
<div class="channel-header">
|
||||||
<div class="channel-banner-container">
|
<div class="channel-banner-container">
|
||||||
<img class="channel-banner" id="channel-banner" src="" alt="Channel banner" />
|
<img
|
||||||
|
class="channel-banner"
|
||||||
|
id="channel-banner"
|
||||||
|
src=""
|
||||||
|
alt="Channel banner"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-info">
|
<div class="channel-info">
|
||||||
<div class="channel-avatar-container">
|
<div class="channel-avatar-container">
|
||||||
<img class="channel-avatar" id="channel-avatar" src="" alt="Channel avatar" />
|
<img
|
||||||
|
class="channel-avatar"
|
||||||
|
id="channel-avatar"
|
||||||
|
src=""
|
||||||
|
alt="Channel avatar"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-details">
|
<div class="channel-details">
|
||||||
<div class="channel-title-container">
|
<div class="channel-title-container">
|
||||||
<h1 class="channel-title" id="channel-title"></h1>
|
<h1 class="channel-title" id="channel-title"></h1>
|
||||||
<span class="channel-verified" id="channel-verified" style="display: none"><i
|
<span
|
||||||
class="fas fa-check-circle"></i></span>
|
class="channel-verified"
|
||||||
|
id="channel-verified"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-stats">
|
<div class="channel-stats">
|
||||||
<span class="channel-subs" id="channel-subs"></span>
|
<span class="channel-subs" id="channel-subs"></span>
|
||||||
|
@ -72,65 +114,32 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="channel-content">
|
<div class="channel-content">
|
||||||
<div class="loading-container" id="channel-loader">
|
<div class="loading-container" id="channel-loader">
|
||||||
<div class="loader"></div>
|
<div class="loader"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-videos-grid" id="channel-videos-grid" style="display: none"></div>
|
<div
|
||||||
<div class="load-more-container" id="channel-load-more" style="display: none">
|
class="channel-videos-grid"
|
||||||
<div class="loader" id="channel-load-more-spinner" style="display: none"></div>
|
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">
|
<button class="load-more-button" id="channel-load-more-button">
|
||||||
Load More
|
Load More Videos
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<div class="video-detail-stats">
|
|
||||||
<div class="detail-stat">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
<span id="detail-views"></span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-stat">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<span id="detail-duration"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="video-description" id="detail-description"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="related-videos">
|
|
||||||
<h2 class="section-title">Watch More</h2>
|
|
||||||
<div class="related-grid" id="related-grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="video-card-template">
|
<template id="video-card-template">
|
||||||
|
@ -172,9 +181,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="plyr.min.js"></script>
|
<script src="trending.js"></script>
|
||||||
<script src="hls.min.js"></script>
|
</body>
|
||||||
<script src="script.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
1039
public/style.css
1039
public/style.css
File diff suppressed because it is too large
Load diff
|
@ -1,23 +1,7 @@
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const overlay = document.createElement("div");
|
|
||||||
overlay.id = "transition-overlay";
|
|
||||||
overlay.className = "transition-overlay";
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
let activeVideoId = null;
|
|
||||||
let prepareInProgress = false;
|
|
||||||
let activeWs = null;
|
|
||||||
let hlsInstance = null;
|
|
||||||
let previousPage = "trending-page";
|
|
||||||
let lastSearchQuery = "";
|
|
||||||
let activeChannelId = null;
|
|
||||||
let channelNextPageData = null;
|
|
||||||
let isLoadingMoreVideos = false;
|
|
||||||
|
|
||||||
const trendingPage = document.getElementById("trending-page");
|
const trendingPage = document.getElementById("trending-page");
|
||||||
const searchResultsPage = document.getElementById("search-results-page");
|
const searchResultsPage = document.getElementById("search-results-page");
|
||||||
const channelPage = document.getElementById("channel-page");
|
const channelPage = document.getElementById("channel-page");
|
||||||
const videoPlayerPage = document.getElementById("video-player-page");
|
|
||||||
const trendingGrid = document.getElementById("trending-grid");
|
const trendingGrid = document.getElementById("trending-grid");
|
||||||
const trendingLoader = document.getElementById("trending-loader");
|
const trendingLoader = document.getElementById("trending-loader");
|
||||||
const searchGrid = document.getElementById("search-grid");
|
const searchGrid = document.getElementById("search-grid");
|
||||||
|
@ -26,8 +10,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
const searchCountText = document.getElementById("search-count");
|
const searchCountText = document.getElementById("search-count");
|
||||||
const searchInput = document.getElementById("search-input");
|
const searchInput = document.getElementById("search-input");
|
||||||
const searchButton = document.getElementById("search-button");
|
const searchButton = document.getElementById("search-button");
|
||||||
const relatedGrid = document.getElementById("related-grid");
|
|
||||||
const playerWrapper = document.getElementById("player-wrapper");
|
|
||||||
const logo = document.getElementById("logo");
|
const logo = document.getElementById("logo");
|
||||||
const channelVideosGrid = document.getElementById("channel-videos-grid");
|
const channelVideosGrid = document.getElementById("channel-videos-grid");
|
||||||
const channelLoader = document.getElementById("channel-loader");
|
const channelLoader = document.getElementById("channel-loader");
|
||||||
|
@ -39,60 +21,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
const channelSubs = document.getElementById("channel-subs");
|
const channelSubs = document.getElementById("channel-subs");
|
||||||
const channelDescription = document.getElementById("channel-description");
|
const channelDescription = document.getElementById("channel-description");
|
||||||
|
|
||||||
const preparationOverlay = document.getElementById("preparation-overlay");
|
|
||||||
const preparationStatus = document.getElementById("preparation-status");
|
|
||||||
const preparationProgressBar = document.getElementById(
|
|
||||||
"preparation-progress-bar"
|
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
let player = null;
|
|
||||||
|
|
||||||
const videoCardTemplate = document.getElementById("video-card-template");
|
const videoCardTemplate = document.getElementById("video-card-template");
|
||||||
const skeletonTemplate = document.getElementById("skeleton-template");
|
const skeletonTemplate = document.getElementById("skeleton-template");
|
||||||
|
|
||||||
|
let lastSearchQuery = "";
|
||||||
|
let activeChannelId = null;
|
||||||
|
let channelNextPageData = null;
|
||||||
|
let isLoadingMoreVideos = false;
|
||||||
|
|
||||||
const trendingApiUrl = "/api/trending";
|
const trendingApiUrl = "/api/trending";
|
||||||
const searchApiUrl = "/api/search";
|
const searchApiUrl = "/api/search";
|
||||||
const wsProcotol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
|
||||||
const wsBaseUrl = wsProcotol + window.location.host + "/api/v1/prepare";
|
|
||||||
const reloadButton = document.getElementById("reload-button");
|
|
||||||
|
|
||||||
reloadButton.addEventListener("click", () => {
|
function formatViews(views) {
|
||||||
window.location.reload();
|
if (views >= 1000000) {
|
||||||
});
|
return `${(views / 1000000).toFixed(1)}M`;
|
||||||
|
} else if (views >= 1000) {
|
||||||
function switchPage(fromPageId, toPageId) {
|
return `${(views / 1000).toFixed(1)}K`;
|
||||||
const fromPage = document.getElementById(fromPageId);
|
} else {
|
||||||
const toPage = document.getElementById(toPageId);
|
return views.toString();
|
||||||
|
}
|
||||||
if (!fromPage || !toPage) return;
|
|
||||||
|
|
||||||
if (toPageId !== "video-player-page") {
|
|
||||||
previousPage = fromPageId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fromPage.classList.add("zoom-out");
|
function formatDuration(seconds) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
setTimeout(() => {
|
if (hours > 0) {
|
||||||
fromPage.classList.remove("active");
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs
|
||||||
fromPage.classList.remove("zoom-out");
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toPage.classList.add("zoom-in");
|
function formatSubscribers(count) {
|
||||||
toPage.classList.add("active");
|
if (count >= 1000000) {
|
||||||
|
return `${(count / 1000000).toFixed(1)}M subscribers`;
|
||||||
requestAnimationFrame(() => {
|
} else if (count >= 1000) {
|
||||||
requestAnimationFrame(() => {
|
return `${(count / 1000).toFixed(1)}K subscribers`;
|
||||||
toPage.classList.remove("zoom-in");
|
} else {
|
||||||
});
|
return `${count} subscribers`;
|
||||||
});
|
}
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashParams() {
|
function getHashParams() {
|
||||||
|
@ -125,117 +96,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupResources() {
|
function switchPage(fromPageId, toPageId) {
|
||||||
if (activeWs && activeWs.readyState === WebSocket.OPEN) {
|
const fromPage = document.getElementById(fromPageId);
|
||||||
activeWs.close();
|
const toPage = document.getElementById(toPageId);
|
||||||
activeWs = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hlsInstance) {
|
if (!fromPage || !toPage) return;
|
||||||
hlsInstance.destroy();
|
|
||||||
hlsInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player) {
|
fromPage.classList.add("zoom-out");
|
||||||
player.destroy();
|
|
||||||
player = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
prepareInProgress = false;
|
setTimeout(() => {
|
||||||
}
|
fromPage.classList.remove("active");
|
||||||
|
fromPage.classList.remove("zoom-out");
|
||||||
|
|
||||||
function getVideoElement() {
|
toPage.classList.add("zoom-in");
|
||||||
const existingVideo = playerWrapper.querySelector("video");
|
toPage.classList.add("active");
|
||||||
if (existingVideo) {
|
|
||||||
existingVideo.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoElement = document.createElement("video");
|
requestAnimationFrame(() => {
|
||||||
videoElement.id = "video-player";
|
requestAnimationFrame(() => {
|
||||||
videoElement.setAttribute("controls", "");
|
toPage.classList.remove("zoom-in");
|
||||||
videoElement.setAttribute("crossorigin", "");
|
|
||||||
videoElement.setAttribute("playsinline", "");
|
|
||||||
|
|
||||||
playerWrapper.appendChild(videoElement);
|
|
||||||
|
|
||||||
return videoElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchVideoDetails(videoId) {
|
|
||||||
try {
|
|
||||||
if (videoId === activeVideoId) {
|
|
||||||
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));
|
|
||||||
|
|
||||||
if (video) {
|
|
||||||
openVideoPlayer(video);
|
|
||||||
} else {
|
|
||||||
console.error("Video not found in trending");
|
|
||||||
trendingLoader.style.display = "none";
|
|
||||||
alert("Video not found. Showing trending videos instead.");
|
|
||||||
goToTrendingPage();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching video details:", error);
|
|
||||||
trendingLoader.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToTrendingPage() {
|
|
||||||
cleanupResources();
|
|
||||||
|
|
||||||
activeVideoId = null;
|
|
||||||
|
|
||||||
activeChannelId = null;
|
|
||||||
|
|
||||||
updateHash();
|
|
||||||
|
|
||||||
if (videoPlayerPage.classList.contains("active")) {
|
|
||||||
switchPage("video-player-page", "trending-page");
|
|
||||||
} else if (searchResultsPage.classList.contains("active")) {
|
|
||||||
switchPage("search-results-page", "trending-page");
|
|
||||||
} else if (channelPage.classList.contains("active")) {
|
|
||||||
switchPage("channel-page", "trending-page");
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTrendingVideos();
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkInitialHash() {
|
|
||||||
const params = getHashParams();
|
|
||||||
if (params.videoId) {
|
|
||||||
fetchVideoDetails(params.videoId);
|
|
||||||
} else if (params.search) {
|
|
||||||
performSearch(params.search);
|
|
||||||
} else if (params.channelId) {
|
|
||||||
fetchChannelData(params.channelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", () => {
|
|
||||||
const params = getHashParams();
|
|
||||||
if (params.videoId) {
|
|
||||||
fetchVideoDetails(params.videoId);
|
|
||||||
} else if (params.search) {
|
|
||||||
performSearch(params.search);
|
|
||||||
} else if (params.channelId) {
|
|
||||||
fetchChannelData(params.channelId);
|
|
||||||
} else {
|
|
||||||
goToTrendingPage();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}, 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 observeElements = () => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
@ -261,81 +159,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatViews(views) {
|
function openVideoPlayer(video) {
|
||||||
if (views >= 1000000) {
|
sessionStorage.setItem("currentVideo", JSON.stringify(video));
|
||||||
return `${(views / 1000000).toFixed(1)}M`;
|
sessionStorage.setItem("lastSearchQuery", lastSearchQuery);
|
||||||
} else if (views >= 1000) {
|
|
||||||
return `${(views / 1000).toFixed(1)}K`;
|
|
||||||
} else {
|
|
||||||
return views.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSkeletons(container, count = 12) {
|
const videoId = video.url.replace("/watch?v=", "");
|
||||||
container.innerHTML = "";
|
window.location.href = `video.html?v=${videoId}`;
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatUploadTime(timestamp) {
|
|
||||||
const now = new Date();
|
|
||||||
const uploadDate = new Date(timestamp);
|
|
||||||
const diffMs = now - uploadDate;
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 1) {
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
if (diffHours < 1) {
|
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
||||||
return `${diffMinutes} minutes ago`;
|
|
||||||
}
|
|
||||||
return `${diffHours} hours ago`;
|
|
||||||
} else if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
} else if (diffDays < 30) {
|
|
||||||
const diffWeeks = Math.floor(diffDays / 7);
|
|
||||||
return `${diffWeeks} weeks ago`;
|
|
||||||
} else if (diffDays < 365) {
|
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
|
||||||
return `${diffMonths} months ago`;
|
|
||||||
} else {
|
|
||||||
const diffYears = Math.floor(diffDays / 365);
|
|
||||||
return `${diffYears} years ago`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 createVideoCard(video, index) {
|
function createVideoCard(video, index) {
|
||||||
|
@ -375,8 +204,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
uploadTime.textContent = video.uploadedDate || "Unknown date";
|
uploadTime.textContent = video.uploadedDate || "Unknown date";
|
||||||
|
|
||||||
videoCard.style.animationDelay = `${index * 0.05}s`;
|
videoCard.style.animationDelay = `${index * 0.05}s`;
|
||||||
|
videoCard.addEventListener("click", async (e) => {
|
||||||
videoCard.addEventListener("click", (e) => {
|
|
||||||
if (
|
if (
|
||||||
e.target === uploaderAvatar ||
|
e.target === uploaderAvatar ||
|
||||||
e.target === uploaderName ||
|
e.target === uploaderName ||
|
||||||
|
@ -384,17 +212,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
) {
|
) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (video.uploaderUrl) {
|
if (video.uploaderUrl) {
|
||||||
openChannelPage(video.uploaderUrl.replace("/channel/", ""));
|
const username = video.uploaderUrl;
|
||||||
|
openChannelPage(username);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
openVideoPlayer(video);
|
openVideoPlayer(video);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
uploader.addEventListener("click", (e) => {
|
uploader.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (video.uploaderUrl) {
|
if (video.uploaderUrl) {
|
||||||
openChannelPage(video.uploaderUrl.replace("/channel/", ""));
|
const username = video.uploaderUrl;
|
||||||
|
openChannelPage(username);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -416,6 +246,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
const videos = await response.json();
|
const videos = await response.json();
|
||||||
|
|
||||||
|
sessionStorage.setItem("trendingVideos", JSON.stringify(videos));
|
||||||
|
|
||||||
showSkeletons(trendingGrid);
|
showSkeletons(trendingGrid);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -437,9 +269,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
trendingGrid.style.display = "block";
|
trendingGrid.style.display = "block";
|
||||||
trendingGrid.innerHTML = `
|
trendingGrid.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<i class="fas fa-exclamation-circle fa-3x" style="margin-bottom: 16px; color: #ff5555;"></i>
|
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||||
<p>Error loading videos. Please try again later.</p>
|
<h3>Failed to load videos</h3>
|
||||||
<p style="font-size: 0.9rem; margin-top: 8px; color: var(--text-secondary);">${error.message}</p>
|
<p>Please try again later.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -449,13 +281,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (!query || query.trim() === "") return;
|
if (!query || query.trim() === "") return;
|
||||||
|
|
||||||
lastSearchQuery = query.trim();
|
lastSearchQuery = query.trim();
|
||||||
|
|
||||||
updateHash({ search: lastSearchQuery });
|
updateHash({ search: lastSearchQuery });
|
||||||
|
|
||||||
if (trendingPage.classList.contains("active")) {
|
if (trendingPage.classList.contains("active")) {
|
||||||
switchPage("trending-page", "search-results-page");
|
switchPage("trending-page", "search-results-page");
|
||||||
} else if (videoPlayerPage.classList.contains("active")) {
|
|
||||||
switchPage("video-player-page", "search-results-page");
|
|
||||||
} else if (channelPage.classList.contains("active")) {
|
} else if (channelPage.classList.contains("active")) {
|
||||||
switchPage("channel-page", "search-results-page");
|
switchPage("channel-page", "search-results-page");
|
||||||
}
|
}
|
||||||
|
@ -484,8 +313,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
? searchData.items.filter((item) => item.type === "stream")
|
? searchData.items.filter((item) => item.type === "stream")
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
sessionStorage.setItem("searchResults", JSON.stringify(results));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${results.length} video results out of ${searchData.items ? searchData.items.length : 0
|
`Found ${results.length} video results out of ${
|
||||||
|
searchData.items ? searchData.items.length : 0
|
||||||
} total items`
|
} total items`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -496,7 +328,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
searchGrid.style.display = "block";
|
searchGrid.style.display = "block";
|
||||||
searchGrid.innerHTML = `
|
searchGrid.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<i class="fas fa-search fa-3x" style="margin-bottom: 16px; color: var(--text-secondary);"></i>
|
<i class="fas fa-search fa-3x"></i>
|
||||||
|
<h3>No videos found</h3>
|
||||||
<p>No video results found for "${lastSearchQuery}"</p>
|
<p>No video results found for "${lastSearchQuery}"</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -520,220 +353,34 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
searchGrid.style.display = "block";
|
searchGrid.style.display = "block";
|
||||||
searchGrid.innerHTML = `
|
searchGrid.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<i class="fas fa-exclamation-circle fa-3x" style="margin-bottom: 16px; color: #ff5555;"></i>
|
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||||
|
<h3>Search failed</h3>
|
||||||
<p>Error searching videos. Please try again later.</p>
|
<p>Error searching videos. Please try again later.</p>
|
||||||
<p style="font-size: 0.9rem; margin-top: 8px; color: var(--text-secondary);">${error.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializePlayer(streamUrl) {
|
function goToTrendingPage() {
|
||||||
const videoElement = getVideoElement();
|
updateHash();
|
||||||
|
|
||||||
if (player) {
|
if (searchResultsPage.classList.contains("active")) {
|
||||||
player.destroy();
|
switchPage("search-results-page", "trending-page");
|
||||||
}
|
|
||||||
|
|
||||||
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(videoId) {
|
|
||||||
if (prepareInProgress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prepareInProgress = true;
|
|
||||||
|
|
||||||
preparationOverlay.style.display = "flex";
|
|
||||||
preparationStatus.textContent = "Preparing video...";
|
|
||||||
preparationProgressBar.style.width = "0%";
|
|
||||||
|
|
||||||
const cleanVideoId = videoId.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. Reloading the page fixes this.";
|
|
||||||
preparationProgressBar.style.width = "100%";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
activeWs.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
preparationStatus.textContent =
|
|
||||||
"Error preparing video. Please try again.";
|
|
||||||
prepareInProgress = false;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
preparationOverlay.style.display = "none";
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
activeWs.onclose = () => {
|
|
||||||
console.log("WebSocket connection closed");
|
|
||||||
if (activeWs === activeWs) {
|
|
||||||
activeWs = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function openVideoPlayer(video) {
|
|
||||||
cleanupResources();
|
|
||||||
|
|
||||||
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) {
|
|
||||||
openChannelPage(video.uploaderUrl.replace("/channel/", ""));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trendingPage.classList.contains("active")) {
|
|
||||||
switchPage("trending-page", "video-player-page");
|
|
||||||
} else if (searchResultsPage.classList.contains("active")) {
|
|
||||||
switchPage("search-results-page", "video-player-page");
|
|
||||||
} else if (channelPage.classList.contains("active")) {
|
} else if (channelPage.classList.contains("active")) {
|
||||||
switchPage("channel-page", "video-player-page");
|
switchPage("channel-page", "trending-page");
|
||||||
}
|
}
|
||||||
|
|
||||||
window.scrollTo(0, 0);
|
fetchTrendingVideos();
|
||||||
|
|
||||||
const videoId = video.url.replace("/watch?v=", "");
|
|
||||||
updateHash({ videoId });
|
|
||||||
|
|
||||||
activeVideoId = videoId;
|
|
||||||
|
|
||||||
prepareVideo(video.url);
|
|
||||||
|
|
||||||
fetchRelatedVideos(video);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRelatedVideos(currentVideo) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(trendingApiUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Network response was not ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
let 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 = `<p>Error loading related videos.</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChannelData(channelId) {
|
async function fetchChannelData(channelId) {
|
||||||
try {
|
try {
|
||||||
if (channelId === activeChannelId) {
|
const resolvedChannelId = await resolveChannelId(channelId);
|
||||||
|
|
||||||
|
if (resolvedChannelId === activeChannelId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeChannelId = channelId;
|
activeChannelId = resolvedChannelId;
|
||||||
|
|
||||||
channelNextPageData = null;
|
channelNextPageData = null;
|
||||||
|
|
||||||
updateHash({ channelId });
|
updateHash({ channelId });
|
||||||
|
@ -742,8 +389,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
switchPage("trending-page", "channel-page");
|
switchPage("trending-page", "channel-page");
|
||||||
} else if (searchResultsPage.classList.contains("active")) {
|
} else if (searchResultsPage.classList.contains("active")) {
|
||||||
switchPage("search-results-page", "channel-page");
|
switchPage("search-results-page", "channel-page");
|
||||||
} else if (videoPlayerPage.classList.contains("active")) {
|
|
||||||
switchPage("video-player-page", "channel-page");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
channelLoader.style.display = "flex";
|
channelLoader.style.display = "flex";
|
||||||
|
@ -756,11 +401,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
channelAvatar.src = "";
|
channelAvatar.src = "";
|
||||||
channelBanner.src = "";
|
channelBanner.src = "";
|
||||||
channelVerified.style.display = "none";
|
channelVerified.style.display = "none";
|
||||||
|
let finalChannelId = channelId;
|
||||||
|
|
||||||
const url = `https://pipedapi.wireway.ch/channel/${channelId}`;
|
if (!channelId.startsWith("UC")) {
|
||||||
console.log(`Fetching channel data from: ${url}`);
|
const url = `/api/channel/${channelId}`;
|
||||||
|
console.log(`Resolving channel ID from: ${url}`);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const idResponse = await fetch(url);
|
||||||
|
if (!idResponse.ok) {
|
||||||
|
throw new Error(`Network response was not ok: ${idResponse.status}`);
|
||||||
|
}
|
||||||
|
const idData = await idResponse.json();
|
||||||
|
finalChannelId = idData.channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipedUrl = `https://pipedapi.wireway.ch/channel/${finalChannelId}`;
|
||||||
|
console.log(`Fetching channel data from: ${pipedUrl}`);
|
||||||
|
const response = await fetch(pipedUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Network response was not ok: ${response.status}`);
|
throw new Error(`Network response was not ok: ${response.status}`);
|
||||||
|
@ -847,8 +504,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
} else {
|
} else {
|
||||||
channelVideosGrid.innerHTML = `
|
channelVideosGrid.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<i class="fas fa-video fa-3x" style="margin-bottom: 16px; color: var(--text-secondary);"></i>
|
<i class="fas fa-video fa-3x"></i>
|
||||||
<p>No videos available</p>
|
<h3>No videos available</h3>
|
||||||
|
<p>This channel hasn't uploaded any videos yet.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -862,9 +520,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
channelVideosGrid.style.display = "block";
|
channelVideosGrid.style.display = "block";
|
||||||
channelVideosGrid.innerHTML = `
|
channelVideosGrid.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<i class="fas fa-exclamation-circle fa-3x" style="margin-bottom: 16px; color: #ff5555;"></i>
|
<i class="fas fa-exclamation-circle fa-3x"></i>
|
||||||
|
<h3>Failed to load channel</h3>
|
||||||
<p>Error loading channel. Please try again later.</p>
|
<p>Error loading channel. Please try again later.</p>
|
||||||
<p style="font-size: 0.9rem; margin-top: 8px; color: var(--text-secondary);">${error.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -887,7 +545,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
loadMoreButton.style.display = "none";
|
loadMoreButton.style.display = "none";
|
||||||
loadMoreSpinner.style.display = "block";
|
loadMoreSpinner.style.display = "block";
|
||||||
|
|
||||||
const url = `https:
|
const url = `https://pipedapi.wireway.ch/nextpage/channel/${activeChannelId}?nextpage=${encodeURIComponent(
|
||||||
channelNextPageData
|
channelNextPageData
|
||||||
)}`;
|
)}`;
|
||||||
console.log(`Fetching next page data from: ${url}`);
|
console.log(`Fetching next page data from: ${url}`);
|
||||||
|
@ -943,19 +601,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
const loadMoreContainer = document.getElementById("channel-load-more");
|
const loadMoreContainer = document.getElementById("channel-load-more");
|
||||||
loadMoreContainer.innerHTML = `
|
loadMoreContainer.innerHTML = `
|
||||||
<div class="load-more-error">Error loading more videos. Tap to try again.</div>
|
<div class="no-results">
|
||||||
|
<p>Error loading more videos. Click to try again.</p>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setTimeout(() => {
|
loadMoreContainer.addEventListener("click", () => {
|
||||||
loadMoreContainer.innerHTML = `
|
loadMoreContainer.innerHTML = `
|
||||||
<div class="loader" id="channel-load-more-spinner" style="display: none;"></div>
|
<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>
|
<button class="load-more-button" id="channel-load-more-button">Load More Videos</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("channel-load-more-button")
|
.getElementById("channel-load-more-button")
|
||||||
.addEventListener("click", loadMoreChannelVideos);
|
.addEventListener("click", loadMoreChannelVideos);
|
||||||
}, 3000);
|
});
|
||||||
} finally {
|
} finally {
|
||||||
document.getElementById("channel-load-more-spinner").style.display =
|
document.getElementById("channel-load-more-spinner").style.display =
|
||||||
"none";
|
"none";
|
||||||
|
@ -963,6 +622,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openChannelPage(channelIdOrUsername) {
|
||||||
|
try {
|
||||||
|
// Remove any existing hash params
|
||||||
|
const channelId = await resolveChannelId(channelIdOrUsername);
|
||||||
|
updateHash({ channelId });
|
||||||
|
fetchChannelData(channelId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening channel page:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInitialHash() {
|
||||||
|
const params = getHashParams();
|
||||||
|
if (params.search) {
|
||||||
|
searchInput.value = params.search;
|
||||||
|
performSearch(params.search);
|
||||||
|
} else if (params.channelId) {
|
||||||
|
fetchChannelData(params.channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
window.addEventListener("scroll", function () {
|
window.addEventListener("scroll", function () {
|
||||||
if (
|
if (
|
||||||
|
@ -987,14 +667,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
.addEventListener("click", loadMoreChannelVideos);
|
.addEventListener("click", loadMoreChannelVideos);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChannelPage(channelId) {
|
|
||||||
if (videoPlayerPage.classList.contains("active")) {
|
|
||||||
cleanupResources();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchChannelData(channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
logo.addEventListener("click", () => {
|
logo.addEventListener("click", () => {
|
||||||
goToTrendingPage();
|
goToTrendingPage();
|
||||||
});
|
});
|
||||||
|
@ -1009,6 +681,37 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
const params = getHashParams();
|
||||||
|
if (params.search) {
|
||||||
|
performSearch(params.search);
|
||||||
|
} else if (params.channelId) {
|
||||||
|
fetchChannelData(params.channelId);
|
||||||
|
} else {
|
||||||
|
goToTrendingPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolveChannelId(username) {
|
||||||
|
if (!username) return null;
|
||||||
|
if (!username.startsWith("@")) return username;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/channel/${encodeURIComponent(username)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to resolve channel ID for:", username);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.channelId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resolving channel ID:", error);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchTrendingVideos();
|
fetchTrendingVideos();
|
||||||
checkInitialHash();
|
checkInitialHash();
|
||||||
setupInfiniteScroll();
|
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>
|
534
public/video.js
Normal file
534
public/video.js
Normal file
|
@ -0,0 +1,534 @@
|
||||||
|
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", async (e) => {
|
||||||
|
const uploader = card.querySelector(".uploader");
|
||||||
|
if (
|
||||||
|
e.target === uploaderAvatar ||
|
||||||
|
e.target === uploaderName ||
|
||||||
|
(e.target.parentElement && e.target.parentElement === uploader)
|
||||||
|
) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (video.uploaderUrl) {
|
||||||
|
const username = video.uploaderUrl;
|
||||||
|
const channelId = await getChannelId(username);
|
||||||
|
window.location.href = `index.html#channelId=${channelId}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function getChannelId(username) {
|
||||||
|
try {
|
||||||
|
if (username.startsWith("UC")) {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/channel/${encodeURIComponent(username)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch channel ID: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.channelId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching channel ID:", error);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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", async () => {
|
||||||
|
if (video.uploaderUrl) {
|
||||||
|
const username = video.uploaderUrl;
|
||||||
|
const channelId = await getChannelId(username);
|
||||||
|
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();
|
||||||
|
});
|
|
@ -15,7 +15,7 @@ Repiped uses yt-dlp to fetch the videos and ffmpeg to stream them.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Repiped supports Windows and Linux operating systems. ffmpeg and yt-dlp are provided in the deps folder.
|
Repiped supports Windows and Linux operating systems. Yt-dlp are provided in the deps folder.
|
||||||
To install follow these simple steps:
|
To install follow these simple steps:
|
||||||
|
|
||||||
### Running on the normal system
|
### Running on the normal system
|
||||||
|
|
41
routes/channel.js
Normal file
41
routes/channel.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
const cheerio = require("cheerio");
|
||||||
|
|
||||||
|
function channelRouteHandler(app) {
|
||||||
|
app.get("/api/channel/:username", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const username = req.params.username;
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).json({ error: "Username is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`https://www.youtube.com/${username}`);
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const canonicalLink = $('link[rel="canonical"]').attr("href");
|
||||||
|
|
||||||
|
if (!canonicalLink) {
|
||||||
|
return res.status(404).json({ error: "Channel not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelIdMatch = canonicalLink.match(/\/channel\/([\w-]+)/);
|
||||||
|
const channelId = channelIdMatch ? channelIdMatch[1] : null;
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
return res.status(404).json({ error: "Could not extract channel ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
channelId: channelId,
|
||||||
|
username: username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching channel ID:", error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to fetch channel ID",
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = channelRouteHandler;
|
|
@ -1,5 +1,5 @@
|
||||||
const https = require("https");
|
const https = require("https");
|
||||||
const yts = require('yt-search');
|
const yts = require("yt-search");
|
||||||
|
|
||||||
function searchRouteHandler(app) {
|
function searchRouteHandler(app) {
|
||||||
app.get("/api/search", async (req, res) => {
|
app.get("/api/search", async (req, res) => {
|
||||||
|
@ -10,32 +10,36 @@ function searchRouteHandler(app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await yts(query);
|
const results = await yts(query);
|
||||||
console.log(results.videos[0])
|
console.log(results.videos[0]);
|
||||||
|
|
||||||
const formattedResults = {
|
const formattedResults = {
|
||||||
items: results.videos.map(video => ({
|
items: results.videos.map((video) => ({
|
||||||
url: `/watch?v=${video.videoId}`,
|
url: `/watch?v=${video.videoId}`,
|
||||||
type: "stream",
|
type: "stream",
|
||||||
title: video.title,
|
title: video.title,
|
||||||
thumbnail: `/api/thumbnail/${video.videoId}`,
|
thumbnail: `/api/thumbnail/${video.videoId}`,
|
||||||
uploaderName: video.author.name,
|
uploaderName: video.author.name,
|
||||||
uploaderUrl: `/channel/${video.author.url}`,
|
uploaderUrl: `${video.author.url.replace(
|
||||||
uploaderAvatar: `/api/avatar/${video.author.url.replace("https://youtube.com/", "")}.png`,
|
"https://youtube.com/",
|
||||||
|
""
|
||||||
|
)}`,
|
||||||
|
uploaderAvatar: `/api/avatar/${video.author.url.replace(
|
||||||
|
"https://youtube.com/",
|
||||||
|
""
|
||||||
|
)}.png`,
|
||||||
uploadedDate: video.ago,
|
uploadedDate: video.ago,
|
||||||
shortDescription: video.description,
|
shortDescription: video.description,
|
||||||
duration: video.seconds,
|
duration: video.seconds,
|
||||||
views: video.views,
|
views: video.views,
|
||||||
uploaded: new Date(video.timestamp * 1000).getTime(),
|
uploaded: new Date(video.timestamp * 1000).getTime(),
|
||||||
uploaderVerified: false,
|
isShort: video.duration.seconds < 60,
|
||||||
isShort: video.duration.seconds < 60
|
|
||||||
})),
|
})),
|
||||||
nextpage: "",
|
nextpage: "",
|
||||||
suggestion: "",
|
suggestion: "",
|
||||||
corrected: false
|
corrected: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(formattedResults);
|
res.json(formattedResults);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error searching videos: ${err.message}`);
|
console.error(`Error searching videos: ${err.message}`);
|
||||||
res.status(500).send("Error searching videos");
|
res.status(500).send("Error searching videos");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue