feat: add home page with resume functionality and recently added VOD
This commit is contained in:
@@ -4368,4 +4368,180 @@ kbd {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Home Dashboard Styles
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
#page-home {
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar by default, show on hover */
|
||||||
|
.horizontal-scroll::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
flex: 0 0 calc(50% - 10px);
|
||||||
|
/* 2 per view on small screens */
|
||||||
|
max-width: 240px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4 per view on medium screens */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.dashboard-card {
|
||||||
|
flex: 0 0 calc(25% - 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6 per view on large screens */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.dashboard-card {
|
||||||
|
flex: 0 0 calc(16.66% - 17px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover .card-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: 0 0 10px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon-overlay svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover .play-icon-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-bright);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
+16
-2
@@ -45,6 +45,13 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="navbar-menu" id="navbar-menu">
|
<div class="navbar-menu" id="navbar-menu">
|
||||||
<a href="#" class="nav-link active" data-page="home">
|
<a href="#" class="nav-link active" data-page="home">
|
||||||
|
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
class="icon">
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg></span>
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-link" data-page="live">
|
||||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
class="icon">
|
class="icon">
|
||||||
<path
|
<path
|
||||||
@@ -88,8 +95,14 @@
|
|||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Home Page -->
|
<!-- Home (Dashboard) Page -->
|
||||||
<div id="page-home" class="page active">
|
<div id="page-home" class="page active">
|
||||||
|
<!-- Content will be rendered by HomePage.js -->
|
||||||
|
<div class="loading-state">Loading Dashboard...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live TV Page (formerly Home) -->
|
||||||
|
<div id="page-live" class="page">
|
||||||
<div class="home-layout">
|
<div class="home-layout">
|
||||||
<!-- Channel Toggle Button (Mobile) -->
|
<!-- Channel Toggle Button (Mobile) -->
|
||||||
<button class="channel-toggle-btn" id="channel-toggle-btn">
|
<button class="channel-toggle-btn" id="channel-toggle-btn">
|
||||||
@@ -1041,7 +1054,8 @@
|
|||||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||||
<script src="/js/pages/Home.js?v=3"></script>
|
<script src="/js/pages/HomePage.js?v=2"></script>
|
||||||
|
<script src="/js/pages/LivePage.js?v=2"></script>
|
||||||
<script src="/js/pages/Guide.js?v=2"></script>
|
<script src="/js/pages/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class App {
|
|||||||
|
|
||||||
// Initialize page controllers
|
// Initialize page controllers
|
||||||
this.pages.home = new HomePage(this);
|
this.pages.home = new HomePage(this);
|
||||||
|
this.pages.live = new LivePage(this);
|
||||||
this.pages.guide = new GuidePage(this);
|
this.pages.guide = new GuidePage(this);
|
||||||
this.pages.movies = new MoviesPage(this);
|
this.pages.movies = new MoviesPage(this);
|
||||||
this.pages.series = new SeriesPage(this);
|
this.pages.series = new SeriesPage(this);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Home Dashboard Page
|
||||||
|
* Features "Continue Watching" and "Recently Added" content
|
||||||
|
*/
|
||||||
|
class HomePage {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.container = null; // Will be set in renderLayout
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Initialization if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
this.renderLayout();
|
||||||
|
await this.loadDashboardData();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
// Cleanup if needed
|
||||||
|
if (this.container) {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLayout() {
|
||||||
|
const pageHome = document.getElementById('page-home');
|
||||||
|
if (!pageHome) return;
|
||||||
|
|
||||||
|
pageHome.innerHTML = `
|
||||||
|
<div class="dashboard-content" id="home-content">
|
||||||
|
<section class="dashboard-section" id="continue-watching-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Continue Watching</h2>
|
||||||
|
</div>
|
||||||
|
<div class="horizontal-scroll" id="continue-watching-list">
|
||||||
|
<div class="loading-state">Loading history...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Recently Added Movies</h2>
|
||||||
|
</div>
|
||||||
|
<div class="horizontal-scroll" id="recent-movies-list">
|
||||||
|
<div class="empty-state">Coming soon</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Recently Added Series</h2>
|
||||||
|
</div>
|
||||||
|
<div class="horizontal-scroll" id="recent-series-list">
|
||||||
|
<div class="empty-state">Coming soon</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.container = document.getElementById('home-content');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDashboardData() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Load Watch History
|
||||||
|
const history = await window.API.request('GET', '/history?limit=12');
|
||||||
|
if (history && Array.isArray(history)) {
|
||||||
|
this.renderHistory(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load Recent Items
|
||||||
|
this.renderRecentMovies();
|
||||||
|
this.renderRecentSeries();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Error loading data:', err);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHistory(items) {
|
||||||
|
const list = document.getElementById('continue-watching-list');
|
||||||
|
const section = document.getElementById('continue-watching-section');
|
||||||
|
|
||||||
|
if (!list || !section) return;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
list.innerHTML = items.map(item => this.createCard(item)).join('');
|
||||||
|
|
||||||
|
// Attach click listeners
|
||||||
|
list.querySelectorAll('.dashboard-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const id = card.dataset.id;
|
||||||
|
const item = items.find(i => i.item_id === id);
|
||||||
|
if (item) {
|
||||||
|
const type = item.item_type || item.type;
|
||||||
|
if (type === 'series' && !isResume) {
|
||||||
|
this.navigateToSeries(item);
|
||||||
|
} else {
|
||||||
|
this.playItem(item, true); // true for resume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToSeries(item) {
|
||||||
|
if (!this.app.pages.series) return;
|
||||||
|
|
||||||
|
// Prepare the series object as expected by SeriesPage.showSeriesDetails
|
||||||
|
const series = {
|
||||||
|
series_id: item.item_id,
|
||||||
|
sourceId: item.source_id,
|
||||||
|
name: item.name || (item.data ? item.data.title : 'Series'),
|
||||||
|
cover: item.stream_icon || (item.data ? item.data.poster : null),
|
||||||
|
plot: item.data ? item.data.description : '',
|
||||||
|
year: item.data ? item.data.year : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch page
|
||||||
|
this.app.navigateTo('series');
|
||||||
|
|
||||||
|
// Show details (delay slightly to ensure page is visible)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.app.pages.series.showSeriesDetails(series);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderRecentMovies() {
|
||||||
|
const list = document.getElementById('recent-movies-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
|
||||||
|
if (!movies || movies.length === 0) {
|
||||||
|
list.innerHTML = '<div class="no-data">No recent movies found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = movies.map(item => this.createRecentCard(item)).join('');
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
list.querySelectorAll('.dashboard-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const id = card.dataset.id;
|
||||||
|
const item = movies.find(m => m.item_id === id);
|
||||||
|
if (item) this.playItem(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Error loading recent movies:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderRecentSeries() {
|
||||||
|
const list = document.getElementById('recent-series-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
|
||||||
|
if (!series || series.length === 0) {
|
||||||
|
list.innerHTML = '<div class="no-data">No recent series found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = series.map(item => this.createRecentCard(item)).join('');
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
list.querySelectorAll('.dashboard-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const id = card.dataset.id;
|
||||||
|
const item = series.find(s => s.item_id === id);
|
||||||
|
if (item) this.navigateToSeries(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Error loading recent series:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createCard(item) {
|
||||||
|
const { data, progress, duration, item_id } = item;
|
||||||
|
const type = item.item_type || item.type;
|
||||||
|
const percent = Math.min(100, Math.round((progress / duration) * 100));
|
||||||
|
|
||||||
|
// Proxy the poster if it's an external URL
|
||||||
|
const poster = data.poster || '/img/poster-placeholder.jpg';
|
||||||
|
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
|
||||||
|
<div class="card-image">
|
||||||
|
<img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.src='/img/poster-placeholder.jpg'">
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" style="width: ${percent}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="play-icon-overlay">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-title" title="${item.name || data.title}">${item.name || data.title || 'Unknown Title'}</div>
|
||||||
|
<div class="card-subtitle">${data.subtitle || (type === 'movie' ? 'Movie' : 'Series')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecentCard(item) {
|
||||||
|
const { data, item_id } = item;
|
||||||
|
const type = item.type || item.item_type;
|
||||||
|
const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg';
|
||||||
|
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
|
||||||
|
<div class="card-image">
|
||||||
|
<img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.src='/img/poster-placeholder.jpg'">
|
||||||
|
<div class="play-icon-overlay">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-title" title="${item.name || (data && data.title)}">${item.name || (data && data.title) || 'Unknown Title'}</div>
|
||||||
|
<div class="card-subtitle">${(data && data.subtitle) || (type === 'movie' ? 'Movie' : 'Series')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async playItem(item, isResume = false) {
|
||||||
|
if (!this.app.pages.watch) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const type = item.item_type || item.type;
|
||||||
|
const streamType = type === 'movie' ? 'movie' : 'series';
|
||||||
|
const sourceId = item.source_id;
|
||||||
|
const streamId = item.item_id;
|
||||||
|
const container = item.container_extension || 'mp4';
|
||||||
|
|
||||||
|
const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
|
||||||
|
|
||||||
|
if (result && result.url) {
|
||||||
|
const content = {
|
||||||
|
id: item.item_id,
|
||||||
|
type: type,
|
||||||
|
title: item.name || item.data.title,
|
||||||
|
subtitle: item.data.subtitle || (type === 'movie' ? 'Movie' : 'Series'),
|
||||||
|
poster: item.stream_icon || item.data.poster,
|
||||||
|
sourceId: sourceId,
|
||||||
|
resumeTime: isResume ? item.progress : 0,
|
||||||
|
containerExtension: container
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch to watch page
|
||||||
|
this.app.navigateTo('watch');
|
||||||
|
|
||||||
|
this.app.pages.watch.play(content, result.url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Playback failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.HomePage = HomePage;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Home Page Controller
|
* Home Page Controller
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class HomePage {
|
class LivePage {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.handleKeydown = this.handleKeydown.bind(this);
|
this.handleKeydown = this.handleKeydown.bind(this);
|
||||||
@@ -106,4 +106,4 @@ class HomePage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.HomePage = HomePage;
|
window.LivePage = LivePage;
|
||||||
@@ -352,7 +352,8 @@ class MoviesPage {
|
|||||||
year: movie.year || movie.releaseDate?.substring(0, 4),
|
year: movie.year || movie.releaseDate?.substring(0, 4),
|
||||||
rating: movie.rating,
|
rating: movie.rating,
|
||||||
sourceId: movie.sourceId,
|
sourceId: movie.sourceId,
|
||||||
categoryId: movie.category_id
|
categoryId: movie.category_id,
|
||||||
|
containerExtension: container
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,7 +452,8 @@ class SeriesPage {
|
|||||||
seriesId: this.currentSeries?.series_id,
|
seriesId: this.currentSeries?.series_id,
|
||||||
seriesInfo: this.currentSeriesInfo,
|
seriesInfo: this.currentSeriesInfo,
|
||||||
currentSeason: seasonNum,
|
currentSeason: seasonNum,
|
||||||
currentEpisode: episodeNum
|
currentEpisode: episodeNum,
|
||||||
|
containerExtension: container
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ class WatchPage {
|
|||||||
this.nextEpisodeCountdown = 10;
|
this.nextEpisodeCountdown = 10;
|
||||||
this.nextEpisodeInterval = null;
|
this.nextEpisodeInterval = null;
|
||||||
|
|
||||||
|
// Watch history
|
||||||
|
this.historyInterval = null;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +201,8 @@ class WatchPage {
|
|||||||
this.seriesInfo = content.seriesInfo || null;
|
this.seriesInfo = content.seriesInfo || null;
|
||||||
this.currentSeason = content.currentSeason || null;
|
this.currentSeason = content.currentSeason || null;
|
||||||
this.currentEpisode = content.currentEpisode || null;
|
this.currentEpisode = content.currentEpisode || null;
|
||||||
|
this.resumeTime = content.resumeTime || 0;
|
||||||
|
this.containerExtension = content.containerExtension || 'mp4';
|
||||||
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
// Stop any Live TV playback before starting movie/series
|
// Stop any Live TV playback before starting movie/series
|
||||||
@@ -240,6 +245,9 @@ class WatchPage {
|
|||||||
await this.checkFavorite();
|
await this.checkFavorite();
|
||||||
// Show overlay initially
|
// Show overlay initially
|
||||||
this.showOverlay();
|
this.showOverlay();
|
||||||
|
|
||||||
|
// Start watch history tracking
|
||||||
|
this.startHistoryTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,7 +281,11 @@ class WatchPage {
|
|||||||
const res = await fetch('/api/transcode/session', {
|
const res = await fetch('/api/transcode/session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url, ...options })
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
seekOffset: this.resumeTime, // Pass resume point to backend
|
||||||
|
...options
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to start session');
|
if (!res.ok) throw new Error('Failed to start session');
|
||||||
const session = await res.json();
|
const session = await res.json();
|
||||||
@@ -346,7 +358,8 @@ class WatchPage {
|
|||||||
if (settings.autoTranscode) {
|
if (settings.autoTranscode) {
|
||||||
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
||||||
try {
|
try {
|
||||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}`);
|
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||||
|
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
||||||
const info = await probeRes.json();
|
const info = await probeRes.json();
|
||||||
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
|
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
|
||||||
|
|
||||||
@@ -360,6 +373,7 @@ class WatchPage {
|
|||||||
this.updateTranscodeStatus('transcoding', statusText);
|
this.updateTranscodeStatus('transcoding', statusText);
|
||||||
const playlistUrl = await this.startTranscodeSession(url, {
|
const playlistUrl = await this.startTranscodeSession(url, {
|
||||||
videoMode,
|
videoMode,
|
||||||
|
seekOffset: this.resumeTime, // Ensure seekOffset is passed
|
||||||
videoCodec: info.video,
|
videoCodec: info.video,
|
||||||
audioCodec: info.audio,
|
audioCodec: info.audio,
|
||||||
audioChannels: info.audioChannels
|
audioChannels: info.audioChannels
|
||||||
@@ -392,7 +406,10 @@ class WatchPage {
|
|||||||
if (settings.forceVideoTranscode) {
|
if (settings.forceVideoTranscode) {
|
||||||
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
|
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
|
||||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
|
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
|
||||||
const playlistUrl = await this.startTranscodeSession(url, { videoMode: 'encode' });
|
const playlistUrl = await this.startTranscodeSession(url, {
|
||||||
|
videoMode: 'encode',
|
||||||
|
seekOffset: this.resumeTime
|
||||||
|
});
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -405,12 +422,17 @@ class WatchPage {
|
|||||||
// Probe to get video codec for HEVC tag handling
|
// Probe to get video codec for HEVC tag handling
|
||||||
let videoCodec = 'unknown';
|
let videoCodec = 'unknown';
|
||||||
try {
|
try {
|
||||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}`);
|
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||||
|
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
||||||
const info = await probeRes.json();
|
const info = await probeRes.json();
|
||||||
videoCodec = info.video;
|
videoCodec = info.video;
|
||||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||||
|
|
||||||
const playlistUrl = await this.startTranscodeSession(url, { videoMode: 'copy', videoCodec });
|
const playlistUrl = await this.startTranscodeSession(url, {
|
||||||
|
videoMode: 'copy',
|
||||||
|
videoCodec,
|
||||||
|
seekOffset: this.resumeTime
|
||||||
|
});
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -509,6 +531,10 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
|
// Stop history tracking and save final progress
|
||||||
|
this.stopHistoryTracking();
|
||||||
|
this.saveProgress();
|
||||||
|
|
||||||
// Cleanup transcode session if exists
|
// Cleanup transcode session if exists
|
||||||
this.stopTranscodeSession();
|
this.stopTranscodeSession();
|
||||||
this.updateTranscodeStatus('hidden');
|
this.updateTranscodeStatus('hidden');
|
||||||
@@ -631,6 +657,17 @@ class WatchPage {
|
|||||||
|
|
||||||
onMetadataLoaded() {
|
onMetadataLoaded() {
|
||||||
// Duration is unreliable for transcoded streams, so we don't display it
|
// Duration is unreliable for transcoded streams, so we don't display it
|
||||||
|
|
||||||
|
// Handle resumption
|
||||||
|
if (this.resumeTime > 0 && this.video) {
|
||||||
|
const duration = this.video.duration;
|
||||||
|
// Only resume if not near the end (95%)
|
||||||
|
if (!duration || this.resumeTime < duration * 0.95) {
|
||||||
|
console.log(`[WatchPage] Resuming at ${this.resumeTime}s`);
|
||||||
|
this.video.currentTime = this.resumeTime;
|
||||||
|
}
|
||||||
|
this.resumeTime = 0; // Reset after use
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlay() {
|
onPlay() {
|
||||||
@@ -1191,6 +1228,50 @@ class WatchPage {
|
|||||||
// Don't stop playback here - allow background playback
|
// Don't stop playback here - allow background playback
|
||||||
this.cancelNextEpisode();
|
this.cancelNextEpisode();
|
||||||
}
|
}
|
||||||
|
// ============================================================
|
||||||
|
// Watch History Tracking
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
startHistoryTracking() {
|
||||||
|
this.stopHistoryTracking(); // Clear existing if any
|
||||||
|
this.historyInterval = setInterval(() => this.saveProgress(), 10000); // 10s
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHistoryTracking() {
|
||||||
|
if (this.historyInterval) {
|
||||||
|
clearInterval(this.historyInterval);
|
||||||
|
this.historyInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveProgress() {
|
||||||
|
if (!this.content || !this.video || this.video.paused) return;
|
||||||
|
|
||||||
|
const progress = Math.floor(this.video.currentTime);
|
||||||
|
const duration = Math.floor(this.video.duration);
|
||||||
|
|
||||||
|
if (isNaN(progress) || isNaN(duration) || duration <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
title: this.content.title || 'Unknown Title',
|
||||||
|
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Movie' : 'Series'),
|
||||||
|
poster: this.content.poster,
|
||||||
|
sourceId: this.content.sourceId,
|
||||||
|
containerExtension: this.containerExtension
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.API.request('POST', '/history', {
|
||||||
|
id: this.content.id,
|
||||||
|
type: this.content.type === 'movie' ? 'movie' : 'episode',
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[History] Failed to save progress:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.WatchPage = WatchPage;
|
window.WatchPage = WatchPage;
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ function initSchema() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
|
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Watch History (per-user)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_history (
|
||||||
|
id TEXT PRIMARY KEY, -- Composite key: user_id:item_id
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
item_type TEXT NOT NULL, -- 'movie', 'episode'
|
||||||
|
item_id TEXT NOT NULL, -- The original item ID (stream_id or composite)
|
||||||
|
parent_id TEXT, -- For episodes (series ID)
|
||||||
|
progress INTEGER DEFAULT 0, -- Current position in seconds
|
||||||
|
duration INTEGER DEFAULT 0, -- Total duration in seconds
|
||||||
|
updated_at INTEGER NOT NULL, -- Timestamp
|
||||||
|
data JSON -- Snapshot of item data (title, poster, etc)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log('[SQLite] Schema initialized');
|
console.log('[SQLite] Schema initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ app.use('/api/remux', require('./routes/remux'));
|
|||||||
app.use('/api/probe', require('./routes/probe'));
|
app.use('/api/probe', require('./routes/probe'));
|
||||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
|
app.use('/api/history', require('./routes/history'));
|
||||||
|
|
||||||
// Version endpoint
|
// Version endpoint
|
||||||
app.get('/api/version', (req, res) => {
|
app.get('/api/version', (req, res) => {
|
||||||
|
|||||||
@@ -288,5 +288,34 @@ router.post('/hide/all', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get recent movies or series
|
||||||
|
router.get('/recent', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, limit = 12 } = req.query;
|
||||||
|
if (!type || (type !== 'movie' && type !== 'series')) {
|
||||||
|
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const recentItems = db.prepare(`
|
||||||
|
SELECT * FROM playlist_items
|
||||||
|
WHERE type = ? AND is_hidden = 0
|
||||||
|
ORDER BY added_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(type, parseInt(limit));
|
||||||
|
|
||||||
|
// Parse JSON data for each item
|
||||||
|
const formatted = recentItems.map(item => ({
|
||||||
|
...item,
|
||||||
|
data: JSON.parse(item.data)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(formatted);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error getting recent ${req.query.type}:`, err);
|
||||||
|
res.status(500).json({ error: 'Failed to get recent items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../db/sqlite');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
|
||||||
|
// Middleware to ensure authentication
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/history
|
||||||
|
* Returns the watch history for the authenticated user
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.user.id;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT * FROM watch_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, limit);
|
||||||
|
|
||||||
|
const history = rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
data: JSON.parse(row.data || '{}')
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(history);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error fetching history:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/history
|
||||||
|
* Saves/updates watch progress for an item
|
||||||
|
*/
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { id, type, parentId, progress, duration, data } = req.body;
|
||||||
|
|
||||||
|
if (!id || !type) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields (id, type)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const compositeId = `${userId}:${id}`;
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO watch_history (id, user_id, item_type, item_id, parent_id, progress, duration, updated_at, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
progress = excluded.progress,
|
||||||
|
duration = excluded.duration,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
data = excluded.data
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
compositeId,
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
id.toString(),
|
||||||
|
parentId ? parentId.toString() : null,
|
||||||
|
progress || 0,
|
||||||
|
duration || 0,
|
||||||
|
timestamp,
|
||||||
|
JSON.stringify(data || {})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, timestamp });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error saving progress:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save progress' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/history/:itemId
|
||||||
|
* Removes an item from the user's watch history
|
||||||
|
*/
|
||||||
|
router.delete('/:itemId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.user.id;
|
||||||
|
const itemId = req.params.itemId;
|
||||||
|
|
||||||
|
const compositeId = `${userId}:${itemId}`;
|
||||||
|
|
||||||
|
const stmt = db.prepare('DELETE FROM watch_history WHERE id = ? AND user_id = ?');
|
||||||
|
const result = stmt.run(compositeId, userId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Item not found in history' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error deleting history item:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete history item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -28,11 +28,11 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
|
|||||||
/**
|
/**
|
||||||
* Probe stream with ffprobe
|
* Probe stream with ffprobe
|
||||||
*/
|
*/
|
||||||
function probeStream(url, ffprobePath, timeout = 15000) {
|
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
'-print_format', 'json',
|
'-print_format', 'json',
|
||||||
'-show_streams',
|
'-show_streams',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
@@ -138,12 +138,13 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { url } = req.query;
|
const { url, ua } = req.query;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).json({ error: 'URL parameter is required' });
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffprobePath = req.app.locals.ffprobePath;
|
const ffprobePath = req.app.locals.ffprobePath;
|
||||||
|
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
|
||||||
|
|
||||||
if (!ffprobePath) {
|
if (!ffprobePath) {
|
||||||
// No ffprobe available - assume needs transcoding to be safe
|
// No ffprobe available - assume needs transcoding to be safe
|
||||||
@@ -159,20 +160,20 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cached = probeCache.get(url);
|
const cached = probeCache.get(cacheKey);
|
||||||
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||||
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
||||||
return res.json(cached.result);
|
return res.json(cached.result);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Probe] Probing: ${url.substring(0, 80)}...`);
|
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const probeResult = await probeStream(url, ffprobePath);
|
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||||
const analysis = analyzeProbeResult(probeResult, url);
|
const analysis = analyzeProbeResult(probeResult, url);
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
probeCache.set(url, { result: analysis, timestamp: Date.now() });
|
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||||
|
|
||||||
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||||
|
|||||||
@@ -196,19 +196,16 @@ class TranscodeSession extends EventEmitter {
|
|||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
'-reconnect_delay_max', '3',
|
'-reconnect_delay_max', '3'
|
||||||
'-seekable', '0',
|
|
||||||
// Critical for syncing copied video with transcoded audio if source has non-zero start time
|
|
||||||
'-copyts'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add seek offset if specified
|
args.push('-i', this.url);
|
||||||
|
|
||||||
|
// Add seek offset if specified (as output option to avoid Range requests)
|
||||||
if (this.options.seekOffset > 0) {
|
if (this.options.seekOffset > 0) {
|
||||||
args.push('-ss', String(this.options.seekOffset));
|
args.push('-ss', String(this.options.seekOffset));
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push('-i', this.url);
|
|
||||||
|
|
||||||
// Map streams
|
// Map streams
|
||||||
args.push('-map', '0:v:0');
|
args.push('-map', '0:v:0');
|
||||||
args.push('-map', '0:a:0?');
|
args.push('-map', '0:a:0?');
|
||||||
|
|||||||
Reference in New Issue
Block a user