diff --git a/accessToken.json b/accessToken.json
new file mode 100644
index 0000000..9faf95a
--- /dev/null
+++ b/accessToken.json
@@ -0,0 +1 @@
+{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXh0IiwiZXhwIjoxNzc4NjYzNzg1fQ.Q85axmzZ8CBxtLopbJIF-WhVfBziwmkXnkiNTSObAF8"}
\ No newline at end of file
diff --git a/index.html b/index.html
index 7c589a5..da0922e 100644
--- a/index.html
+++ b/index.html
@@ -2,23 +2,231 @@
-
-
+
+
+
Login / Register
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/main.js b/main.js
index 34525fd..92384cc 100644
--- a/main.js
+++ b/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');
}
@@ -18,4 +35,9 @@ 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
});
\ No newline at end of file
diff --git a/package.json b/package.json
index 222eee3..a82f23f 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "Stream media from the NotPlexServer",
"main": "main.js",
"scripts": {
- "start": "electron ."
+ "start": "electron . --enable-logging"
},
"repository": {
"type": "git",
diff --git a/preload.js b/preload.js
index c9d8be9..aa73f9e 100644
--- a/preload.js
+++ b/preload.js
@@ -1,7 +1,121 @@
-const { contextBridge } = require('electron');
+console.log("Preload script starting...");
+try {
+ const { contextBridge } = require('electron');
+ const fs = require('fs');
+ const path = require('path');
+ console.log("Node modules loaded successfully.");
+
+ // Location to store the token (adjust the location as needed)
+ const tokenFile = path.join(__dirname, 'accessToken.json');
+ console.log("Token file path:", tokenFile);
-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}`
-});
\ No newline at end of file
+ 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);
+}
\ No newline at end of file
diff --git a/renderer.js b/renderer.js
index 2e94a17..c8791e7 100644
--- a/renderer.js
+++ b/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');
- // Load and display movie list
+ 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 = `
${movie.title}
`;
- item.addEventListener('click', () => loadDetails(movie.id));
- listEl.appendChild(item);
+ item.addEventListener('click', () => {
+ window.api.getMovieDetails(movie.id).then(movieDetails => {
+ movieDetailsEl.innerHTML = `
+
${movieDetails.title}
+
${movieDetails.plot}
+
Year: ${movieDetails.year}
+
Released: ${movieDetails.released}
+
+ `;
+ 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);
});
- });
-
- // Fetch and display details + stream
- function loadDetails(id) {
- window.api.getMovieDetails(id).then(movie => {
- detailsEl.innerHTML = `
-
${movie.title}
-
Year: ${movie.year}
-
Released: ${movie.released}
-
+ // 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 = `
+

+
${movie.title}
`;
+ item.addEventListener('click', () => {
+ window.api.getMovieDetails(movie.id).then(movieDetails => {
+ movieDetailsEl.innerHTML = `
+
${movieDetails.title}
+
${movieDetails.plot}
+
Year: ${movieDetails.year}
+
Released: ${movieDetails.released}
+
+ `;
+ 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 = `
+

+
${movieDetails.title}
+ `;
+ movieItem.addEventListener('click', () => {
+ movieDetailsEl.innerHTML = `
+
${movieDetails.title}
+
${movieDetails.plot}
+
Year: ${movieDetails.year}
+
Released: ${movieDetails.released}
+
+ `;
+ 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 = `
+

+
${show.name}
+ `;
+ item.addEventListener('click', () => {
+ window.api.getShowDetails(show.id).then(showDetails => {
+ const showHTML = `
+
${showDetails.name}
+

+
Rating: ${showDetails.rating}
+
Genres: ${showDetails.genres}
+
${showDetails.summary}
+
Seasons:
+ `;
+ window.api.getShowSeasons(show.id).then(seasons => {
+ if (!Array.isArray(seasons)) seasons = Object.values(seasons);
+ const seasonsHTML = seasons
+ .map(s => `
`)
+ .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 = `
+

+
${show.name}
+ `;
+ item.addEventListener('click', () => {
+ window.api.getShowDetails(show.id).then(showDetails => {
+ const showHTML = `
+
${showDetails.name}
+

+
Rating: ${showDetails.rating}
+
Genres: ${showDetails.genres}
+
${showDetails.summary}
+
Seasons:
+ `;
+ window.api.getShowSeasons(show.id).then(seasons => {
+ if (!Array.isArray(seasons)) seasons = Object.values(seasons);
+ const seasonsHTML = seasons
+ .map(s => `
`)
+ .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 = `
+

+
${showDetails.name}
+ `;
+ episodeItem.addEventListener('click', () => {
+ movieDetailsEl.innerHTML = `
+
Episode ${item.media_id}
+
+ `;
+ 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);
+ });
+ });
+
+ 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 = `
Season ${season} Episodes
`;
+
+ 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 = `
+
${ep.title}
+
+ `;
+ 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 += '
No episodes found for this season.
';
+ }
+ })
+ .catch(err => {
+ console.error('Error fetching episodes:', err);
+ movieDetailsEl.innerHTML += '
Error loading episodes.
';
+ });
+ }
+
+ 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 = `
+
${videoDetails.title}
+
${videoDetails.plot}
+
Year: ${videoDetails.year}
+
Released: ${videoDetails.released}
+
+ `;
+ 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;
+ }
});
}
- });
\ No newline at end of file
+ }
+
+ 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);
+ });
+});
\ No newline at end of file