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 @@ - Movie Streamer + NotPlexApp + -

Movie Streamer

-
-
-
+
+
+

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} ${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} + ${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} + ${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} + ${show.name} + `; + item.addEventListener('click', () => { + window.api.getShowDetails(show.id).then(showDetails => { + const showHTML = ` +

${showDetails.name}

+ ${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} + ${show.name} + `; + item.addEventListener('click', () => { + window.api.getShowDetails(show.id).then(showDetails => { + const showHTML = ` +

${showDetails.name}

+ ${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} + ${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