continue watching section, recently added section, new endpoints in collab with server, scan for new files, and more
This commit is contained in:
1
accessToken.json
Normal file
1
accessToken.json
Normal file
@@ -0,0 +1 @@
|
||||
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXh0IiwiZXhwIjoxNzc4NjYzNzg1fQ.Q85axmzZ8CBxtLopbJIF-WhVfBziwmkXnkiNTSObAF8"}
|
||||
232
index.html
232
index.html
@@ -2,23 +2,231 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Movie Streamer</title>
|
||||
<title>NotPlexApp</title>
|
||||
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.11.8/dist/ffmpeg.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
|
||||
#container { display: flex; }
|
||||
#movies-list { width: 30%; overflow-y: auto; }
|
||||
.movie-item { cursor: pointer; margin-bottom: 10px; display: flex; align-items: center; }
|
||||
.movie-item img { width: 50px; height: auto; margin-right: 10px; }
|
||||
#movie-details { flex-grow: 1; padding-left: 20px; }
|
||||
video { max-width: 100%; margin-top: 10px; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
/* Tabs style */
|
||||
#tabs {
|
||||
display: flex;
|
||||
background: #333;
|
||||
}
|
||||
#tabs button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
background: #444;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
#tabs button.active {
|
||||
background: #222;
|
||||
}
|
||||
/* Content lists (item grids inside each section) */
|
||||
.content-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
overflow-x: visible; /* ensure no horizontal scrolling */
|
||||
/* Optional: force items to wrap into multiple rows */
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
/* Section‐container: stack each <section> as a row */
|
||||
.section-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px; /* space between sections */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
height: calc(100% - 48px); /* adjust for tab height */
|
||||
overflow-y: auto;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
.movie-item {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
box-shadow: 0px 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
.movie-item img {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* Overlay for details/stream */
|
||||
#overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
#overlay-content {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
#close-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: red;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
video {
|
||||
max-width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
/* Authentication Page Styles */
|
||||
#auth-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
#auth-content {
|
||||
background: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
}
|
||||
#auth-content input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
#auth-content button {
|
||||
width: 45%;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* show a “Remuxing…” banner above the video */
|
||||
.remuxing-indicator {
|
||||
color: #ff0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* new: horizontal scrolling row */
|
||||
.content-row {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 20px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.content-row::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.content-row::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* hide native scrollbar */
|
||||
.content-row {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
}
|
||||
.content-row::-webkit-scrollbar { /* WebKit */
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* wrap arrows + row */
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.scroll-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
.scroll-arrow.left { left: 0; }
|
||||
.scroll-arrow.right { right: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Movie Streamer</h1>
|
||||
<div id="container">
|
||||
<div id="movies-list"></div>
|
||||
<div id="movie-details"></div>
|
||||
<div id="auth-page">
|
||||
<div id="auth-content">
|
||||
<h2>Login / Register</h2>
|
||||
<input type="text" id="username" placeholder="Username" />
|
||||
<input type="password" id="password" placeholder="Password" />
|
||||
<div>
|
||||
<button id="login-btn">Login</button>
|
||||
<button id="register-btn">Register</button>
|
||||
</div>
|
||||
<p id="auth-error" style="color:red;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tabs">
|
||||
<button id="movies-tab" class="active">Movies</button>
|
||||
<button id="shows-tab">TV Shows</button>
|
||||
<button id="scan-btn">Scan for New Files</button> <!-- New button -->
|
||||
</div>
|
||||
|
||||
<div id="sync-controls" style="text-align: center; margin: 10px;">
|
||||
<input type="text" id="sync-session-id" placeholder="Enter Sync Session ID" />
|
||||
<button id="join-sync-btn">Join Sync Session</button>
|
||||
</div>
|
||||
|
||||
<!-- use section-list instead of content-list on the containers -->
|
||||
<div id="movies-list" class="section-list"></div>
|
||||
<div id="shows-list" class="section-list" style="display:none;"></div>
|
||||
|
||||
<div id="overlay">
|
||||
<div id="overlay-content">
|
||||
<button id="close-overlay">Close</button>
|
||||
<div id="movie-details"></div>
|
||||
<!-- Add Sync Controls inside the overlay -->
|
||||
<div id="overlay-sync-controls" style="text-align: center; margin-top: 10px;">
|
||||
<input type="text" id="overlay-sync-session-id" placeholder="Enter Sync Session ID" />
|
||||
<button id="overlay-join-sync-btn">Join Sync Session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
main.js
24
main.js
@@ -1,14 +1,31 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
// Catch unhandled exceptions to prevent crashes
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Unhandled exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false // disable sandbox to enable Node built-ins in preload
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.on('preload-error', (event, preloadPath, error) => {
|
||||
console.error(`Failed to load preload script from ${preloadPath}:`, error);
|
||||
});
|
||||
|
||||
win.loadFile('index.html');
|
||||
}
|
||||
|
||||
@@ -19,3 +36,8 @@ app.on('window-all-closed', () => {
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
||||
app.on('renderer-process-crashed', (event, webContents, killed) => {
|
||||
console.error('Renderer process crashed. Restarting...');
|
||||
createWindow(); // Restart the renderer process
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Stream media from the NotPlexServer",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron ."
|
||||
"start": "electron . --enable-logging"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
122
preload.js
122
preload.js
@@ -1,7 +1,121 @@
|
||||
console.log("Preload script starting...");
|
||||
try {
|
||||
const { contextBridge } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
console.log("Node modules loaded successfully.");
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
getMovies: () => fetch('http://localhost:8000/movies').then(r => r.json()),
|
||||
getMovieDetails: (id) => fetch(`http://localhost:8000/movies/${id}`).then(r => r.json()),
|
||||
getStreamUrl: (id) => `http://localhost:8000/stream/${id}`
|
||||
// Location to store the token (adjust the location as needed)
|
||||
const tokenFile = path.join(__dirname, 'accessToken.json');
|
||||
console.log("Token file path:", tokenFile);
|
||||
|
||||
function loadToken() {
|
||||
try {
|
||||
const data = fs.readFileSync(tokenFile, 'utf8');
|
||||
const parsed = JSON.parse(data);
|
||||
console.log("Token loaded:", parsed.token);
|
||||
return parsed.token;
|
||||
} catch (error) {
|
||||
console.warn("loadToken error:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken(token) {
|
||||
try {
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ token }), 'utf8');
|
||||
console.log("Token saved:", token);
|
||||
} catch (error) {
|
||||
console.error("saveToken error:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken = loadToken();
|
||||
|
||||
// Updated fetchWithAuth: if JSON returns "Invalid or expired token" then
|
||||
// dispatch a custom event that the renderer will catch.
|
||||
function fetchWithAuth(url, options = {}) {
|
||||
options.headers = options.headers || {};
|
||||
if (accessToken) {
|
||||
options.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
return fetch(url, options)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.detail && data.detail === "Invalid or expired token") {
|
||||
console.error("API reports expired token.");
|
||||
accessToken = null;
|
||||
// Dispatch custom event on the window so that the renderer can show the login overlay.
|
||||
window.dispatchEvent(new CustomEvent("tokenExpired", { detail: data.detail }));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// Now update getStreamUrl and getEpisodeStreamUrl to use fetchWithAuth.
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Movies API calls
|
||||
getMovies: () => fetchWithAuth('http://bbrunson.com:8495/movies'),
|
||||
getMovieDetails: (id) => fetchWithAuth(`http://bbrunson.com:8495/movies/${id}`),
|
||||
getStream: (id) => `http://bbrunson.com:8495/stream/${id}`,
|
||||
// TV Shows API calls
|
||||
getShows: () => fetchWithAuth('http://bbrunson.com:8495/shows'),
|
||||
getShowDetails: (showId) => fetchWithAuth(`http://bbrunson.com:8495/shows/${showId}`),
|
||||
getShowSeasons: (showId) => fetchWithAuth(`http://bbrunson.com:8495/shows/${showId}/seasons`),
|
||||
getSeasonEpisodes: (showId, season) =>
|
||||
fetchWithAuth(`http://bbrunson.com:8495/shows/${showId}/seasons/${season}/episodes`),
|
||||
getEpisodeStream: (episodeId) => `http://bbrunson.com:8495/stream_episode/${episodeId}`,
|
||||
getSubtitles: (id) => `http://bbrunson.com:8495/subtitles/${id}`,
|
||||
|
||||
// New: In Progress API call
|
||||
getInProgress: () => fetchWithAuth('http://bbrunson.com:8495/in_progress'),
|
||||
|
||||
// New: Get Episode Details API call
|
||||
getEpisodeDetails: (id) => fetchWithAuth(`http://bbrunson.com:8495/episodes/${id}`),
|
||||
|
||||
// New: Use the /episodes/{episode_id}/show endpoint to fetch TV show details for an episode
|
||||
getShowByEpisode: (episodeId) => fetchWithAuth(`http://bbrunson.com:8495/episodes/${episodeId}/show`),
|
||||
|
||||
// Authentication functions
|
||||
login: (username, password) => {
|
||||
return fetch('http://bbrunson.com:8495/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.access_token) {
|
||||
accessToken = data.access_token;
|
||||
saveToken(accessToken);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
},
|
||||
register: (username, password) => {
|
||||
return fetch('http://bbrunson.com:8495/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
.then(r => r.json());
|
||||
},
|
||||
getToken: () => accessToken,
|
||||
scanForNewFiles: () => fetchWithAuth('http://bbrunson.com:8495/scan', { method: 'POST' }),
|
||||
getSessionDetails: (sessionId) => fetchWithAuth(`http://bbrunson.com:8495/sessions/${sessionId}`),
|
||||
|
||||
// new progress APIs
|
||||
saveProgress: (mediaType, mediaId, lastPosition) =>
|
||||
fetchWithAuth(`http://bbrunson.com:8495/save_progress/${mediaType}/${mediaId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_position: lastPosition })
|
||||
}),
|
||||
getProgress: (mediaType, mediaId) =>
|
||||
fetchWithAuth(`http://bbrunson.com:8495/get_progress/${mediaType}/${mediaId}`)
|
||||
});
|
||||
|
||||
console.log("Preload script finished register API.");
|
||||
} catch (error) {
|
||||
console.error('Error in preload.js:', error);
|
||||
}
|
||||
845
renderer.js
845
renderer.js
@@ -1,30 +1,849 @@
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const listEl = document.getElementById('movies-list');
|
||||
const detailsEl = document.getElementById('movie-details');
|
||||
let cancelAutoAdvance = false; // Global flag for auto-advance cancellation
|
||||
let syncSocket = null; // WebSocket for video sync
|
||||
let isSyncing = false; // Flag to prevent infinite loops during sync
|
||||
|
||||
const authPage = document.getElementById('auth-page');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const registerBtn = document.getElementById('register-btn');
|
||||
const authError = document.getElementById('auth-error');
|
||||
|
||||
const moviesListEl = document.getElementById('movies-list');
|
||||
const showsListEl = document.getElementById('shows-list');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const movieDetailsEl = document.getElementById('movie-details');
|
||||
const closeOverlayBtn = document.getElementById('close-overlay');
|
||||
const moviesTabBtn = document.getElementById('movies-tab');
|
||||
const showsTabBtn = document.getElementById('shows-tab');
|
||||
const scanBtn = document.getElementById('scan-btn');
|
||||
|
||||
// Listen for token expiration events from preload.
|
||||
window.addEventListener("tokenExpired", () => {
|
||||
console.warn("Access token expired. Redirecting to login screen.");
|
||||
authPage.style.display = 'flex';
|
||||
});
|
||||
|
||||
// Modified authentication check: hide the login overlay if a token is loaded.
|
||||
function checkAuth() {
|
||||
if (window.api.getToken()) {
|
||||
authPage.style.display = 'none';
|
||||
} else {
|
||||
authPage.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
window.api.login(username, password).then(data => {
|
||||
if(data.access_token) {
|
||||
authPage.style.display = 'none';
|
||||
loadMovies();
|
||||
} else {
|
||||
authError.textContent = data.error || 'Login failed';
|
||||
}
|
||||
}).catch(err => {
|
||||
authError.textContent = 'Login error';
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
registerBtn.addEventListener('click', () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
window.api.register(username, password).then(data => {
|
||||
if(data.success) {
|
||||
authError.textContent = 'Registration successful. Please log in.';
|
||||
} else {
|
||||
authError.textContent = data.error || 'Registration failed';
|
||||
}
|
||||
}).catch(err => {
|
||||
authError.textContent = 'Registration error';
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to boost video volume using the Web Audio API
|
||||
function boostVideoVolume(video) {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaElementSource(video);
|
||||
const gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = 5; // 200% volume boost
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
} catch (err) {
|
||||
console.error('Error boosting video volume:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// New: transcodeAudioStream uses ffmpeg.wasm to re-encode the audio
|
||||
async function transcodeAudioStream(videoUrl) {
|
||||
// Ensure ffmpeg is loaded only once
|
||||
if (!window.ffmpegInstance) {
|
||||
const { createFFmpeg, fetchFile } = FFmpeg;
|
||||
window.ffmpegInstance = createFFmpeg({ log: true });
|
||||
await window.ffmpegInstance.load();
|
||||
}
|
||||
const ffmpeg = window.ffmpegInstance;
|
||||
|
||||
try {
|
||||
// Fetch the original video file as an ArrayBuffer
|
||||
const response = await fetch(videoUrl);
|
||||
const data = await response.arrayBuffer();
|
||||
ffmpeg.FS('writeFile', 'input.mp4', new Uint8Array(data));
|
||||
|
||||
// Run ffmpeg command to copy the video stream and transcode the audio to AAC
|
||||
await ffmpeg.run('-i', 'input.mp4', '-c:v', 'copy', '-c:a', 'aac', 'output.mp4');
|
||||
const outputData = ffmpeg.FS('readFile', 'output.mp4');
|
||||
// Clean up the virtual FS
|
||||
ffmpeg.FS('unlink', 'input.mp4');
|
||||
ffmpeg.FS('unlink', 'output.mp4');
|
||||
|
||||
const blob = new Blob([outputData.buffer], { type: 'video/mp4' });
|
||||
const transcodedUrl = URL.createObjectURL(blob);
|
||||
return transcodedUrl;
|
||||
} catch (error) {
|
||||
console.error("Transcoding error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// New: try direct play, detect “no audio” or error, then remux on demand
|
||||
async function safePlayVideo(videoElement, videoUrl, subtitlesUrl = null) {
|
||||
// 1) wire up subtitles (same as you had)
|
||||
if (subtitlesUrl) {
|
||||
let track = videoElement.querySelector('track');
|
||||
if (!track) {
|
||||
track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.label = 'English';
|
||||
track.srclang = 'en';
|
||||
videoElement.appendChild(track);
|
||||
}
|
||||
track.src = subtitlesUrl;
|
||||
track.default = true;
|
||||
}
|
||||
|
||||
// 2) helper that tries to play + checks for audioTracks.length
|
||||
const tryPlay = url => new Promise((resolve, reject) => {
|
||||
videoElement.src = url;
|
||||
videoElement.load();
|
||||
const onErr = () => cleanup() || reject();
|
||||
const onPlay = () => {
|
||||
setTimeout(() => {
|
||||
const hasAudio = videoElement.audioTracks
|
||||
? videoElement.audioTracks.length > 0
|
||||
: true; // assume “ok” if browser doesn’t support audioTracks
|
||||
cleanup();
|
||||
hasAudio ? resolve() : reject();
|
||||
}, 200);
|
||||
};
|
||||
function cleanup() {
|
||||
videoElement.removeEventListener('error', onErr);
|
||||
videoElement.removeEventListener('play', onPlay);
|
||||
return true;
|
||||
}
|
||||
videoElement.addEventListener('error', onErr);
|
||||
videoElement.addEventListener('play', onPlay);
|
||||
videoElement.play().catch(onErr);
|
||||
});
|
||||
|
||||
try {
|
||||
// first attempt: raw MKV
|
||||
await tryPlay(videoUrl);
|
||||
} catch (_) {
|
||||
console.warn('Direct play failed or no audio → remuxing…');
|
||||
|
||||
// insert the indicator right above the video
|
||||
const indicator = document.createElement('div');
|
||||
indicator.classList.add('remuxing-indicator');
|
||||
indicator.textContent = 'Remuxing...';
|
||||
videoElement.parentNode.insertBefore(indicator, videoElement);
|
||||
|
||||
// do the heavy FFmpeg step
|
||||
const mp4url = await transcodeAudioStream(videoUrl);
|
||||
await tryPlay(mp4url);
|
||||
|
||||
// remove the banner
|
||||
indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Add error handling for video playback
|
||||
function playVideo(videoElement, videoUrl, subtitlesUrl = null) {
|
||||
try {
|
||||
const mediaType = videoElement.dataset.mediaType;
|
||||
const mediaId = videoElement.dataset.mediaId;
|
||||
|
||||
window.api.getProgress(mediaType, mediaId)
|
||||
.then(data => {
|
||||
// Determine progress; default to 0 if none found.
|
||||
const progress = (data.last_position && data.last_position > 0) ? data.last_position : 0;
|
||||
|
||||
// Listen for metadata load to seek to the progress point.
|
||||
videoElement.addEventListener('loadedmetadata', function setProgress() {
|
||||
if (progress > 0) {
|
||||
videoElement.currentTime = progress;
|
||||
}
|
||||
videoElement.removeEventListener('loadedmetadata', setProgress);
|
||||
});
|
||||
|
||||
safePlayVideo(videoElement, videoUrl, subtitlesUrl)
|
||||
.then(() => {
|
||||
// Define helper functions to start and stop the progress timer.
|
||||
function startProgressTimer() {
|
||||
if (!videoElement.progressTimer) {
|
||||
videoElement.progressTimer = setInterval(() => {
|
||||
window.api.saveProgress(mediaType, mediaId, videoElement.currentTime)
|
||||
.catch(err => console.error("Error saving progress:", err));
|
||||
}, 60000); // 5 minutes in ms
|
||||
}
|
||||
}
|
||||
function stopProgressTimer() {
|
||||
if (videoElement.progressTimer) {
|
||||
clearInterval(videoElement.progressTimer);
|
||||
videoElement.progressTimer = null;
|
||||
}
|
||||
}
|
||||
videoElement.addEventListener('play', startProgressTimer);
|
||||
videoElement.addEventListener('pause', stopProgressTimer);
|
||||
videoElement.addEventListener('ended', stopProgressTimer);
|
||||
|
||||
if (!videoElement.paused) {
|
||||
startProgressTimer();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Playback failed even after remux:', err));
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('Failed to fetch progress:', err);
|
||||
// Continue with playback even if fetching progress failed.
|
||||
safePlayVideo(videoElement, videoUrl, subtitlesUrl)
|
||||
.then(() => {
|
||||
function startProgressTimer() {
|
||||
if (!videoElement.progressTimer) {
|
||||
videoElement.progressTimer = setInterval(() => {
|
||||
window.api.saveProgress(mediaType, mediaId, videoElement.currentTime)
|
||||
.catch(err => console.error("Error saving progress:", err));
|
||||
}, 300000);
|
||||
}
|
||||
}
|
||||
function stopProgressTimer() {
|
||||
if (videoElement.progressTimer) {
|
||||
clearInterval(videoElement.progressTimer);
|
||||
videoElement.progressTimer = null;
|
||||
}
|
||||
}
|
||||
videoElement.addEventListener('play', startProgressTimer);
|
||||
videoElement.addEventListener('pause', stopProgressTimer);
|
||||
videoElement.addEventListener('ended', stopProgressTimer);
|
||||
|
||||
if (!videoElement.paused) {
|
||||
startProgressTimer();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Playback failed even after remux:', err));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Unexpected playback error:', err);
|
||||
alert('An unexpected error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to switch active tab and content
|
||||
function setActiveTab(tab) {
|
||||
if (tab === 'movies') {
|
||||
moviesTabBtn.classList.add('active');
|
||||
showsTabBtn.classList.remove('active');
|
||||
moviesListEl.style.display = ''; // use CSS default (i.e. .section-list)
|
||||
showsListEl.style.display = 'none';
|
||||
} else {
|
||||
moviesTabBtn.classList.remove('active');
|
||||
showsTabBtn.classList.add('active');
|
||||
moviesListEl.style.display = 'none';
|
||||
showsListEl.style.display = ''; // use CSS default (i.e. .section-list)
|
||||
}
|
||||
}
|
||||
|
||||
moviesTabBtn.addEventListener('click', () => {
|
||||
setActiveTab('movies');
|
||||
});
|
||||
showsTabBtn.addEventListener('click', () => {
|
||||
setActiveTab('shows');
|
||||
if (!showsListEl.hasChildNodes()) loadShows();
|
||||
});
|
||||
|
||||
// Load and display movie list
|
||||
function loadMovies() {
|
||||
// New: Load and display in-progress movies
|
||||
loadInProgressMovies();
|
||||
|
||||
window.api.getMovies().then(movies => {
|
||||
movies.forEach(movie => {
|
||||
// --- Newest Movies Section ---
|
||||
const sectionNewest = document.createElement('section');
|
||||
const headerNewest = document.createElement('h2');
|
||||
headerNewest.textContent = 'Newest Movies';
|
||||
sectionNewest.appendChild(headerNewest);
|
||||
|
||||
const gridNewest = document.createElement('div');
|
||||
gridNewest.classList.add('content-row');
|
||||
// sort by created_at descending (newest first)
|
||||
const moviesByNewest = [...movies].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
moviesByNewest.forEach(movie => {
|
||||
const item = document.createElement('div');
|
||||
item.classList.add('movie-item');
|
||||
item.innerHTML = `
|
||||
<img src="${movie.poster !== 'N/A' ? movie.poster : ''}" alt="${movie.title}" />
|
||||
<span>${movie.title}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => loadDetails(movie.id));
|
||||
listEl.appendChild(item);
|
||||
item.addEventListener('click', () => {
|
||||
window.api.getMovieDetails(movie.id).then(movieDetails => {
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>${movieDetails.title}</h2>
|
||||
<p>${movieDetails.plot}</p>
|
||||
<p><strong>Year:</strong> ${movieDetails.year}</p>
|
||||
<p><strong>Released:</strong> ${movieDetails.released}</p>
|
||||
<video id="movie-video" controls autoplay
|
||||
data-media-id="${movieDetails.id}"
|
||||
data-media-type="movie"></video>
|
||||
`;
|
||||
overlay.style.display = 'flex';
|
||||
const movieVideo = document.getElementById('movie-video');
|
||||
boostVideoVolume(movieVideo);
|
||||
const streamUrl = window.api.getStream(movieDetails.id);
|
||||
const subtitlesUrl = window.api.getSubtitles(movieDetails.id);
|
||||
playVideo(movieVideo, streamUrl, subtitlesUrl);
|
||||
});
|
||||
});
|
||||
gridNewest.appendChild(item);
|
||||
});
|
||||
// wrap row + arrows
|
||||
const scrollWrap = document.createElement('div');
|
||||
scrollWrap.classList.add('scroll-container');
|
||||
|
||||
// left arrow
|
||||
const leftBtn = document.createElement('button');
|
||||
leftBtn.classList.add('scroll-arrow','left');
|
||||
leftBtn.innerHTML = '◀';
|
||||
leftBtn.addEventListener('click', ()=> {
|
||||
gridNewest.scrollBy({ left: -300, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// right arrow
|
||||
const rightBtn = document.createElement('button');
|
||||
rightBtn.classList.add('scroll-arrow','right');
|
||||
rightBtn.innerHTML = '▶';
|
||||
rightBtn.addEventListener('click', ()=> {
|
||||
gridNewest.scrollBy({ left: 300, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
scrollWrap.append(leftBtn, gridNewest, rightBtn);
|
||||
sectionNewest.appendChild(scrollWrap);
|
||||
moviesListEl.appendChild(sectionNewest);
|
||||
|
||||
// --- Alphabetical Movies Section ---
|
||||
const sectionAlpha = document.createElement('section');
|
||||
const headerAlpha = document.createElement('h2');
|
||||
headerAlpha.textContent = 'All Movies (A–Z)';
|
||||
sectionAlpha.appendChild(headerAlpha);
|
||||
|
||||
const gridAlpha = document.createElement('div');
|
||||
gridAlpha.classList.add('content-list');
|
||||
// sort by title ascending
|
||||
const moviesAlpha = [...movies].sort((a, b) => a.title.localeCompare(b.title));
|
||||
moviesAlpha.forEach(movie => {
|
||||
const item = document.createElement('div');
|
||||
item.classList.add('movie-item');
|
||||
item.innerHTML = `
|
||||
<img src="${movie.poster !== 'N/A' ? movie.poster : ''}" alt="${movie.title}" />
|
||||
<span>${movie.title}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
window.api.getMovieDetails(movie.id).then(movieDetails => {
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>${movieDetails.title}</h2>
|
||||
<p>${movieDetails.plot}</p>
|
||||
<p><strong>Year:</strong> ${movieDetails.year}</p>
|
||||
<p><strong>Released:</strong> ${movieDetails.released}</p>
|
||||
<video id="movie-video" controls autoplay
|
||||
data-media-id="${movieDetails.id}"
|
||||
data-media-type="movie"></video>
|
||||
`;
|
||||
overlay.style.display = 'flex';
|
||||
const movieVideo = document.getElementById('movie-video');
|
||||
boostVideoVolume(movieVideo);
|
||||
const streamUrl = window.api.getStream(movieDetails.id);
|
||||
const subtitlesUrl = window.api.getSubtitles(movieDetails.id);
|
||||
playVideo(movieVideo, streamUrl, subtitlesUrl);
|
||||
});
|
||||
});
|
||||
gridAlpha.appendChild(item);
|
||||
});
|
||||
sectionAlpha.appendChild(gridAlpha);
|
||||
moviesListEl.appendChild(sectionAlpha);
|
||||
});
|
||||
}
|
||||
|
||||
// New: Function to load currently in-progress movies
|
||||
function loadInProgressMovies() {
|
||||
window.api.getInProgress().then(data => {
|
||||
const movieProgress = data.movies || [];
|
||||
if (movieProgress.length > 0) {
|
||||
const sectionInProgress = document.createElement('section');
|
||||
const headerInProgress = document.createElement('h2');
|
||||
headerInProgress.textContent = 'Continue Watching';
|
||||
sectionInProgress.appendChild(headerInProgress);
|
||||
|
||||
const gridInProgress = document.createElement('div');
|
||||
gridInProgress.classList.add('content-row');
|
||||
|
||||
movieProgress.forEach(item => {
|
||||
// item.media_id holds the movie id.
|
||||
window.api.getMovieDetails(item.media_id).then(movieDetails => {
|
||||
const movieItem = document.createElement('div');
|
||||
movieItem.classList.add('movie-item');
|
||||
movieItem.innerHTML = `
|
||||
<img src="${movieDetails.poster !== 'N/A' ? movieDetails.poster : ''}" alt="${movieDetails.title}" />
|
||||
<span>${movieDetails.title}</span>
|
||||
`;
|
||||
movieItem.addEventListener('click', () => {
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>${movieDetails.title}</h2>
|
||||
<p>${movieDetails.plot}</p>
|
||||
<p><strong>Year:</strong> ${movieDetails.year}</p>
|
||||
<p><strong>Released:</strong> ${movieDetails.released}</p>
|
||||
<video id="movie-video" controls autoplay
|
||||
data-media-id="${movieDetails.id}"
|
||||
data-media-type="movie"></video>
|
||||
`;
|
||||
overlay.style.display = 'flex';
|
||||
const movieVideo = document.getElementById('movie-video');
|
||||
boostVideoVolume(movieVideo);
|
||||
const streamUrl = window.api.getStream(movieDetails.id);
|
||||
const subtitlesUrl = window.api.getSubtitles(movieDetails.id);
|
||||
playVideo(movieVideo, streamUrl, subtitlesUrl);
|
||||
});
|
||||
gridInProgress.appendChild(movieItem);
|
||||
}).catch(err => console.error("Failed to load movie details for progress item:", err));
|
||||
});
|
||||
|
||||
sectionInProgress.appendChild(gridInProgress);
|
||||
moviesListEl.insertBefore(sectionInProgress, moviesListEl.firstChild);
|
||||
}
|
||||
}).catch(err => console.error('Error loading in progress movies:', err));
|
||||
}
|
||||
|
||||
// Load and display TV shows list
|
||||
async function loadShows() {
|
||||
// New: Load and display in-progress episodes for TV shows
|
||||
loadInProgressShows();
|
||||
|
||||
const shows = await window.api.getShows();
|
||||
showsListEl.innerHTML = '';
|
||||
|
||||
// --- Newest TV Shows Section (by latest episode added) ---
|
||||
const sectionNewest = document.createElement('section');
|
||||
const headerNewest = document.createElement('h2');
|
||||
headerNewest.textContent = 'Newest TV Shows (by latest episode)';
|
||||
sectionNewest.appendChild(headerNewest);
|
||||
|
||||
const gridNewest = document.createElement('div');
|
||||
gridNewest.classList.add('content-row');
|
||||
|
||||
// Fetch seasons & episodes for each show to find the most recent episode date
|
||||
const showsWithLatestEp = await Promise.all(shows.map(async show => {
|
||||
let seasons = await window.api.getShowSeasons(show.id);
|
||||
if (!Array.isArray(seasons)) seasons = Object.values(seasons);
|
||||
const episodeLists = await Promise.all(
|
||||
seasons.map(season => window.api.getSeasonEpisodes(show.id, season))
|
||||
);
|
||||
const allEpisodes = episodeLists.flat();
|
||||
const latestDate = allEpisodes.reduce((max, ep) => {
|
||||
const dt = new Date(ep.created_at);
|
||||
return dt > max ? dt : max;
|
||||
}, new Date(0));
|
||||
return { show, latestDate };
|
||||
}));
|
||||
|
||||
// Sort shows by their latest episode date (newest first)
|
||||
const showsByNewest = showsWithLatestEp
|
||||
.sort((a, b) => b.latestDate - a.latestDate)
|
||||
.map(item => item.show);
|
||||
|
||||
showsByNewest.forEach(show => {
|
||||
const item = document.createElement('div');
|
||||
item.classList.add('movie-item');
|
||||
item.innerHTML = `
|
||||
<img src="${show.poster || ''}" alt="${show.name}" />
|
||||
<span>${show.name}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
window.api.getShowDetails(show.id).then(showDetails => {
|
||||
const showHTML = `
|
||||
<h2>${showDetails.name}</h2>
|
||||
<img src="${showDetails.poster}" alt="${showDetails.name}" style="max-width:200px;"/>
|
||||
<p><strong>Rating:</strong> ${showDetails.rating}</p>
|
||||
<p><strong>Genres:</strong> ${showDetails.genres}</p>
|
||||
<p>${showDetails.summary}</p>
|
||||
<h3>Seasons:</h3>
|
||||
`;
|
||||
window.api.getShowSeasons(show.id).then(seasons => {
|
||||
if (!Array.isArray(seasons)) seasons = Object.values(seasons);
|
||||
const seasonsHTML = seasons
|
||||
.map(s => `<button class="season-btn" data-show-id="${show.id}" data-season="${s}">Season ${s}</button>`)
|
||||
.join('');
|
||||
movieDetailsEl.innerHTML = showHTML + seasonsHTML;
|
||||
overlay.style.display = 'flex';
|
||||
overlay.querySelectorAll('.season-btn').forEach(btn =>
|
||||
btn.addEventListener('click', seasonBtnHandler)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
gridNewest.appendChild(item);
|
||||
});
|
||||
sectionNewest.appendChild(gridNewest);
|
||||
showsListEl.appendChild(sectionNewest);
|
||||
|
||||
// --- Alphabetical TV Shows Section ---
|
||||
const sectionAlpha = document.createElement('section');
|
||||
const headerAlpha = document.createElement('h2');
|
||||
headerAlpha.textContent = 'All TV Shows (A–Z)';
|
||||
sectionAlpha.appendChild(headerAlpha);
|
||||
|
||||
const gridAlpha = document.createElement('div');
|
||||
gridAlpha.classList.add('content-list');
|
||||
const showsAlpha = [...shows].sort((a, b) => a.name.localeCompare(b.name));
|
||||
showsAlpha.forEach(show => {
|
||||
const item = document.createElement('div');
|
||||
item.classList.add('movie-item');
|
||||
item.innerHTML = `
|
||||
<img src="${show.poster || ''}" alt="${show.name}" />
|
||||
<span>${show.name}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
window.api.getShowDetails(show.id).then(showDetails => {
|
||||
const showHTML = `
|
||||
<h2>${showDetails.name}</h2>
|
||||
<img src="${showDetails.poster}" alt="${showDetails.name}" style="max-width:200px;"/>
|
||||
<p><strong>Rating:</strong> ${showDetails.rating}</p>
|
||||
<p><strong>Genres:</strong> ${showDetails.genres}</p>
|
||||
<p>${showDetails.summary}</p>
|
||||
<h3>Seasons:</h3>
|
||||
`;
|
||||
window.api.getShowSeasons(show.id).then(seasons => {
|
||||
if (!Array.isArray(seasons)) seasons = Object.values(seasons);
|
||||
const seasonsHTML = seasons
|
||||
.map(s => `<button class="season-btn" data-show-id="${show.id}" data-season="${s}">Season ${s}</button>`)
|
||||
.join('');
|
||||
movieDetailsEl.innerHTML = showHTML + seasonsHTML;
|
||||
overlay.style.display = 'flex';
|
||||
overlay.querySelectorAll('.season-btn').forEach(btn =>
|
||||
btn.addEventListener('click', seasonBtnHandler)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
gridAlpha.appendChild(item);
|
||||
});
|
||||
sectionAlpha.appendChild(gridAlpha);
|
||||
showsListEl.appendChild(sectionAlpha);
|
||||
}
|
||||
|
||||
// Updated function to stream the episode without calling getEpisodeDetails
|
||||
function loadInProgressShows() {
|
||||
window.api.getInProgress().then(data => {
|
||||
const episodesProgress = data.episodes || [];
|
||||
if (episodesProgress.length > 0) {
|
||||
const sectionInProgress = document.createElement('section');
|
||||
const headerInProgress = document.createElement('h2');
|
||||
headerInProgress.textContent = 'Continue Watching';
|
||||
sectionInProgress.appendChild(headerInProgress);
|
||||
|
||||
const gridInProgress = document.createElement('div');
|
||||
gridInProgress.classList.add('content-row');
|
||||
|
||||
episodesProgress.forEach(item => {
|
||||
window.api.getShowByEpisode(item.media_id)
|
||||
.then(showDetails => {
|
||||
const episodeItem = document.createElement('div');
|
||||
episodeItem.classList.add('movie-item');
|
||||
episodeItem.innerHTML = `
|
||||
<img src="${showDetails.poster || ''}" alt="${showDetails.name}" />
|
||||
<span>${showDetails.name}</span>
|
||||
`;
|
||||
episodeItem.addEventListener('click', () => {
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>Episode ${item.media_id}</h2>
|
||||
<video id="episode-video" controls autoplay
|
||||
data-media-id="${item.media_id}"
|
||||
data-media-type="episode"></video>
|
||||
`;
|
||||
overlay.style.display = 'flex';
|
||||
const videoElement = document.getElementById('episode-video');
|
||||
const episodeStreamUrl = window.api.getEpisodeStream(item.media_id);
|
||||
playVideo(videoElement, episodeStreamUrl);
|
||||
});
|
||||
gridInProgress.appendChild(episodeItem);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to fetch show by episode details:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display details + stream
|
||||
function loadDetails(id) {
|
||||
window.api.getMovieDetails(id).then(movie => {
|
||||
detailsEl.innerHTML = `
|
||||
<h2>${movie.title}</h2>
|
||||
<p><strong>Year:</strong> ${movie.year}</p>
|
||||
<p><strong>Released:</strong> ${movie.released}</p>
|
||||
<video controls src="${window.api.getStreamUrl(movie.id)}"></video>
|
||||
sectionInProgress.appendChild(gridInProgress);
|
||||
showsListEl.insertBefore(sectionInProgress, showsListEl.firstChild);
|
||||
}
|
||||
}).catch(err => console.error('Error loading in progress shows:', err));
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', () => {
|
||||
scanBtn.disabled = true; // Disable the button to prevent multiple clicks
|
||||
scanBtn.textContent = 'Scanning...';
|
||||
|
||||
window.api.scanForNewFiles().then(response => {
|
||||
if (response.success) {
|
||||
alert('Scan completed successfully!');
|
||||
loadMovies();
|
||||
loadShows();
|
||||
} else {
|
||||
alert('Scan failed: ' + (response.error || 'Unknown error'));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error during scan:', err);
|
||||
alert('An error occurred while scanning for new files.');
|
||||
}).finally(() => {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.textContent = 'Scan for New Files';
|
||||
});
|
||||
});
|
||||
|
||||
// Initial auth check; hide login overlay if token exists and load movies.
|
||||
checkAuth();
|
||||
if (window.api.getToken()) {
|
||||
loadMovies();
|
||||
}
|
||||
|
||||
closeOverlayBtn.addEventListener('click', () => {
|
||||
cancelAutoAdvance = true;
|
||||
const video = overlay.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
// Stop the progress timer if it's running
|
||||
if (video.progressTimer) {
|
||||
clearInterval(video.progressTimer);
|
||||
video.progressTimer = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
overlay.style.display = 'none';
|
||||
});
|
||||
|
||||
// Add handler for season buttons
|
||||
function seasonBtnHandler(event) {
|
||||
const btn = event.currentTarget;
|
||||
const showId = btn.dataset.showId;
|
||||
const season = btn.dataset.season;
|
||||
|
||||
// Clear previous details and show season header
|
||||
movieDetailsEl.innerHTML = `<h2>Season ${season} Episodes</h2>`;
|
||||
|
||||
window.api.getSeasonEpisodes(showId, season)
|
||||
.then(episodes => {
|
||||
if (episodes && episodes.length) {
|
||||
const episodesContainer = document.createElement('div');
|
||||
episodesContainer.classList.add('episodes-list');
|
||||
episodes.forEach(ep => {
|
||||
const epBtn = document.createElement('button');
|
||||
epBtn.classList.add('episode-btn');
|
||||
epBtn.textContent = ep.title || `Episode ${ep.id}`;
|
||||
epBtn.dataset.episodeId = ep.id;
|
||||
epBtn.addEventListener('click', () => {
|
||||
// Display selected episode video in the overlay
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>${ep.title}</h2>
|
||||
<video id="episode-video" controls autoplay
|
||||
data-media-id="${ep.id}"
|
||||
data-media-type="episode"></video>
|
||||
`;
|
||||
const videoElement = document.getElementById('episode-video');
|
||||
const episodeStreamUrl = window.api.getEpisodeStream(ep.id);
|
||||
playVideo(videoElement, episodeStreamUrl);
|
||||
});
|
||||
episodesContainer.appendChild(epBtn);
|
||||
});
|
||||
movieDetailsEl.appendChild(episodesContainer);
|
||||
} else {
|
||||
movieDetailsEl.innerHTML += '<p>No episodes found for this season.</p>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching episodes:', err);
|
||||
movieDetailsEl.innerHTML += '<p>Error loading episodes.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function connectToSyncSession(sessionId, mediaId, mediaType, videoElement = null) {
|
||||
if (syncSocket) {
|
||||
syncSocket.close();
|
||||
}
|
||||
|
||||
// Include media_id and media_type as query parameters in the WebSocket URL
|
||||
syncSocket = new WebSocket(`ws://bbrunson.com:8495/ws/sync/${sessionId}?media_id=${mediaId}&media_type=${mediaType}`);
|
||||
|
||||
syncSocket.onopen = () => {
|
||||
console.log("Connected to sync session:", sessionId);
|
||||
};
|
||||
|
||||
syncSocket.onmessage = async (event) => {
|
||||
if (isSyncing) return; // Prevent loops caused by local updates
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
// Ensure the media_id and media_type match the current video
|
||||
if (message.media_id !== mediaId || message.media_type !== mediaType) {
|
||||
console.warn("Received sync message for a different media_id or media_type. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(message.data);
|
||||
|
||||
// If no video element is provided, fetch video details and start playback
|
||||
if (!videoElement) {
|
||||
try {
|
||||
let videoDetails;
|
||||
if (mediaType === "movie") {
|
||||
videoDetails = await window.api.getMovieDetails(mediaId); // Fetch movie details
|
||||
} else if (mediaType === "episode") {
|
||||
videoDetails = await window.api.getEpisodeDetails(mediaId); // Fetch episode details
|
||||
} else {
|
||||
throw new Error("Unknown media type");
|
||||
}
|
||||
|
||||
movieDetailsEl.innerHTML = `
|
||||
<h2>${videoDetails.title}</h2>
|
||||
<p>${videoDetails.plot}</p>
|
||||
<p><strong>Year:</strong> ${videoDetails.year}</p>
|
||||
<p><strong>Released:</strong> ${videoDetails.released}</p>
|
||||
<video id="movie-video" controls autoplay data-media-id="${videoDetails.id}" data-media-type="${mediaType}"></video>
|
||||
`;
|
||||
overlay.style.display = 'flex';
|
||||
videoElement = document.getElementById('movie-video');
|
||||
boostVideoVolume(videoElement);
|
||||
|
||||
const streamUrl = mediaType === "movie"
|
||||
? window.api.getStream(videoDetails.id)
|
||||
: window.api.getEpisodeStream(videoDetails.id);
|
||||
playVideo(videoElement, streamUrl);
|
||||
} catch (error) {
|
||||
console.error("Error fetching video details:", error);
|
||||
alert("Failed to load video for the sync session.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentTime = videoElement.currentTime;
|
||||
const timeDifference = Math.abs(currentTime - data.currentTime);
|
||||
|
||||
if (data.type === "play") {
|
||||
if (timeDifference > 5) {
|
||||
videoElement.currentTime = data.currentTime;
|
||||
}
|
||||
videoElement.play();
|
||||
} else if (data.type === "pause") {
|
||||
if (timeDifference > 5) {
|
||||
videoElement.currentTime = data.currentTime;
|
||||
}
|
||||
videoElement.pause();
|
||||
} else if (data.type === "seek") {
|
||||
if (timeDifference > 5) {
|
||||
videoElement.currentTime = data.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncSocket.onclose = () => {
|
||||
console.log("Disconnected from sync session:", sessionId);
|
||||
};
|
||||
|
||||
syncSocket.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
// Sync video events with the server
|
||||
if (videoElement) {
|
||||
videoElement.addEventListener("play", () => {
|
||||
if (syncSocket.readyState === WebSocket.OPEN) {
|
||||
isSyncing = true;
|
||||
syncSocket.send(JSON.stringify({ type: "play", currentTime: videoElement.currentTime }));
|
||||
isSyncing = false;
|
||||
}
|
||||
});
|
||||
|
||||
videoElement.addEventListener("pause", () => {
|
||||
if (syncSocket.readyState === WebSocket.OPEN) {
|
||||
isSyncing = true;
|
||||
syncSocket.send(JSON.stringify({ type: "pause", currentTime: videoElement.currentTime }));
|
||||
isSyncing = false;
|
||||
}
|
||||
});
|
||||
|
||||
videoElement.addEventListener("seeked", () => {
|
||||
if (syncSocket.readyState === WebSocket.OPEN) {
|
||||
isSyncing = true;
|
||||
syncSocket.send(JSON.stringify({ type: "seek", currentTime: videoElement.currentTime }));
|
||||
isSyncing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("join-sync-btn").addEventListener("click", async () => {
|
||||
const sessionId = document.getElementById("sync-session-id").value.trim();
|
||||
if (!sessionId) {
|
||||
alert("Please enter a valid session ID.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch session details from the server
|
||||
let mediaId, mediaType;
|
||||
try {
|
||||
const sessionDetails = await window.api.getSessionDetails(sessionId);
|
||||
mediaId = sessionDetails.media_id;
|
||||
mediaType = sessionDetails.media_type;
|
||||
} catch (error) {
|
||||
console.error("Error fetching session details:", error);
|
||||
alert("Failed to retrieve session details. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mediaId || !mediaType) {
|
||||
alert("Media ID or type is missing. Unable to join the sync session.");
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = document.querySelector("video");
|
||||
connectToSyncSession(sessionId, mediaId, mediaType, videoElement);
|
||||
});
|
||||
|
||||
document.getElementById("overlay-join-sync-btn").addEventListener("click", () => {
|
||||
const sessionId = document.getElementById("overlay-sync-session-id").value.trim();
|
||||
if (!sessionId) {
|
||||
alert("Please enter a valid session ID.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Automatically retrieve the mediaId from the currently playing video in the overlay
|
||||
const videoElement = document.querySelector("#overlay video");
|
||||
if (!videoElement || !videoElement.dataset.mediaId) {
|
||||
alert("No video is currently playing or media ID is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaId = videoElement.dataset.mediaId; // Retrieve mediaId from the video element
|
||||
connectToSyncSession(sessionId, mediaId, "movie", videoElement);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user