├── Screenshot_gradient.png ├── Assets └── Images │ └── loadingcat.webp ├── Screenshot 2024-09-28 200619.png ├── Screenshot 2024-09-29 201734.png ├── .idea ├── .gitignore ├── vcs.xml ├── jsLibraryMappings.xml ├── modules.xml └── PlexampStatusPage.iml ├── scripts ├── state.js ├── main.js ├── utils.js ├── api.js ├── themes.js └── ui.js ├── docker └── Dockerfile ├── README.md ├── index.html ├── LICENSE └── styles └── style.css /Screenshot_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claesbert/PlexampStatusPage/HEAD/Screenshot_gradient.png -------------------------------------------------------------------------------- /Assets/Images/loadingcat.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claesbert/PlexampStatusPage/HEAD/Assets/Images/loadingcat.webp -------------------------------------------------------------------------------- /Screenshot 2024-09-28 200619.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claesbert/PlexampStatusPage/HEAD/Screenshot 2024-09-28 200619.png -------------------------------------------------------------------------------- /Screenshot 2024-09-29 201734.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claesbert/PlexampStatusPage/HEAD/Screenshot 2024-09-29 201734.png -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /scripts/state.js: -------------------------------------------------------------------------------- 1 | // scripts/state.js 2 | 3 | export const state = { 4 | lastMediaId: '', 5 | totalDuration: 0, 6 | currentOffset: 0, // Client-side playback position in milliseconds 7 | lastUpdate: Date.now(), 8 | isPlaying: false, 9 | }; 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/PlexampStatusPage.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | // scripts/main.js 2 | 3 | import { startFetchingNowPlaying } from './api.js'; 4 | import { updateClock, initializeUI } from './ui.js'; 5 | import { getCurrentTheme, applyTheme } from './themes.js'; 6 | 7 | document.addEventListener('DOMContentLoaded', () => { 8 | // Apply the current theme 9 | applyTheme(getCurrentTheme()); 10 | 11 | // Initialize UI components and event listeners 12 | initializeUI(); 13 | 14 | // Start the clock 15 | updateClock(); 16 | setInterval(updateClock, 1000); 17 | 18 | // Start fetching now playing info 19 | startFetchingNowPlaying(); 20 | }); 21 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Start with an official Apache image 2 | FROM httpd:latest 3 | 4 | # Install git to clone the repository and update Apache configuration to serve from /var/www/html 5 | RUN apt-get update && \ 6 | apt-get install -y git && \ 7 | rm -rf /var/lib/apt/lists/* && \ 8 | mkdir -p /var/www/html && \ 9 | sed -i 's|/usr/local/apache2/htdocs|/var/www/html|g' /usr/local/apache2/conf/httpd.conf 10 | 11 | # Clone the GitHub repository into the new Apache document root 12 | RUN git clone https://github.com/claesbert/PlexampStatusPage /var/www/html 13 | 14 | # Expose port 80 to make the server accessible 15 | EXPOSE 80 16 | 17 | # Start the Apache server (CMD is inherited from httpd:latest) 18 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | // scripts/utils.js 2 | 3 | export function getPlexCredentials() { 4 | const plexToken = localStorage.getItem('plexToken') || ''; 5 | const plexIP = localStorage.getItem('plexIP') || ''; 6 | return { plexToken, plexIP }; 7 | } 8 | 9 | export function savePlexCredentials(plexToken, plexIP) { 10 | localStorage.setItem('plexToken', plexToken); 11 | localStorage.setItem('plexIP', plexIP); 12 | } 13 | 14 | export function formatTime(milliseconds) { 15 | const totalSeconds = Math.floor(milliseconds / 1000); 16 | const minutes = Math.floor(totalSeconds / 60); 17 | const seconds = totalSeconds % 60; 18 | return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; 19 | } 20 | 21 | export function getTimeFormat() { 22 | return localStorage.getItem('timeFormat') || '24h'; 23 | } 24 | 25 | export function setTimeFormat(format) { 26 | localStorage.setItem('timeFormat', format); 27 | } 28 | -------------------------------------------------------------------------------- /scripts/api.js: -------------------------------------------------------------------------------- 1 | // scripts/api.js 2 | 3 | import { updateNowPlayingUI, resetNowPlaying, useHttps } from './ui.js'; 4 | import { getPlexCredentials } from './utils.js'; 5 | 6 | let fetchIntervalId = null; 7 | 8 | export function startFetchingNowPlaying() { 9 | fetchNowPlaying(); 10 | if (fetchIntervalId) clearInterval(fetchIntervalId); 11 | fetchIntervalId = setInterval(fetchNowPlaying, 5000); // Fetch every 5 seconds 12 | } 13 | 14 | export async function fetchNowPlaying() { 15 | const { plexToken, plexIP } = getPlexCredentials(); 16 | if (!plexToken || !plexIP) return; 17 | 18 | // Use the imported HTTPS setting 19 | const protocol = useHttps() ? 'https' : 'http'; 20 | 21 | const url = `${protocol}://${plexIP}:32400/status/sessions?X-Plex-Token=${plexToken}`; 22 | try { 23 | const response = await fetch(url); 24 | const data = await response.text(); 25 | const parser = new DOMParser(); 26 | const xmlDoc = parser.parseFromString(data, 'text/xml'); 27 | 28 | const media = xmlDoc.querySelector('Video') || xmlDoc.querySelector('Track'); 29 | 30 | if (media) { 31 | const mediaId = media.getAttribute('ratingKey'); 32 | const title = media.getAttribute('title'); 33 | 34 | // Handle Various Artists issue 35 | let artist; 36 | if (media.getAttribute('grandparentTitle') === 'Various Artists') { 37 | artist = 38 | media.getAttribute('originalTitle') || 39 | media.getAttribute('title') || 40 | 'Unknown Artist'; 41 | } else { 42 | artist = 43 | media.getAttribute('grandparentTitle') || 44 | media.getAttribute('parentTitle') || 45 | 'Unknown Artist'; 46 | } 47 | 48 | const album = media.getAttribute('parentTitle'); 49 | const albumYear = media.getAttribute('parentYear') || ''; 50 | const coverUrl = media.getAttribute('thumb'); 51 | const viewOffset = parseInt(media.getAttribute('viewOffset')) || 0; 52 | const duration = parseInt(media.getAttribute('duration')) || 1; 53 | 54 | updateNowPlayingUI({ 55 | mediaId, 56 | title, 57 | artist, 58 | album, 59 | albumYear, 60 | coverUrl, 61 | plexIP, 62 | plexToken, 63 | viewOffset, 64 | duration, 65 | }); 66 | } else { 67 | // No media is playing 68 | resetNowPlaying(); 69 | } 70 | } catch (error) { 71 | console.error('Error fetching now playing data:', error); 72 | resetNowPlaying(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plexamp Now Playing Dashboard 2 | 3 | A sleek minimal web dashboard that displays the currently playing track on Plexamp with dynamic themes and real-time progress tracking. 4 | 5 | ![Alt text](Screenshot_gradient.png) 6 | 7 | 8 | ## Features 9 | 10 | - **Real-Time Now Playing Information**: Shows track title, artist, album, and album art. 11 | - **Smooth Progress Bar**: Visually tracks playback progress without skipping. (I mention this specifically because there was a bug that took up like 1/3rd of development time) 12 | - **Dynamic Themes**: 13 | - **Light & Dark**: Classic themes for different lighting preferences. 14 | - **Gradient, Pastel & Glass**: Dynamic backgrounds based on album art colors. 15 | - **Time Format Toggle**: Switch between 24-hour and 12-hour (AM/PM) formats. 16 | 17 | ## Installation 18 | 19 | 1. **Clone the Repository** 20 | 2. **Navigate to the Project Directory** 21 | 3. **Open `index.html` in Your Browser** 22 | - Warning: If your server doesn't support HTTPS/Only supports HTTPS make sure the toggle in the settings modal reflects this. Otherwise it won't pull data. 23 | 24 | ## Configuration 25 | 26 | 1. **Open the Settings Modal** 27 | - Click the **Settings** button on the dashboard. 28 | 29 | 2. **Enter Plex Server Details** 30 | - **Plex Server IP**: Your Plex server's IP address. 31 | - **Plex Token**: Your Plex server's access token. 32 | - Find your plex token [here](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) 33 | 34 | 3. **Select Theme and Time Format** 35 | - Choose your preferred theme from the dropdown. 36 | - Select the desired time format (24-hour is default). 37 | 38 | 4. **Save Settings** 39 | - Click **Save Settings** to apply your preferences. 40 | 41 | ## Usage 42 | 43 | - **View Now Playing**: The dashboard will automatically display the current track playing on Plexamp. 44 | - **Monitor Progress**: The progress bar updates in real-time to reflect playback. 45 | - **Change Settings**: Reopen the settings modal anytime to update your Plex server details or change themes and time formats. 46 | 47 | ## Dependencies 48 | 49 | - **[Vibrant.js](https://github.com/Vibrant-Colors/node-vibrant)**: Used for extracting prominent colors from album art for dynamic theming. 50 | 51 | ## License 52 | 53 | Creative Commons Zero v1.0 Universal 54 | _Basiclly: Do whatever the fuck you want with this, we waive copyright, it is part of the public domain_ 55 | 56 | _I'll admit idk what it means to use a library like vibrant.js in a CC0 codebase. Like obviously it is covered by its own license._ 57 | 58 | ## Known (big) issues 59 | - Currently pausing behavior is weird/broken. (Feel free to fix it so I don't have to <3) 60 | 61 | ## Originally made by these idiots 62 | [Bert Claes](https://github.com/claesbert/) & [Marnick De Grave](https://github.com/protobear) 63 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Now Playing on Plexamp 6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 | Album Art 17 |
18 |
19 |

Track Title

20 |

Artist Name

21 |

Album Name (Year)

22 |
23 |
24 |
25 |
26 |
27 |
28 | 0:00 29 | 0:00 30 |
31 |
32 |
33 |
34 | 35 | 36 | 39 | 40 | 41 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /scripts/themes.js: -------------------------------------------------------------------------------- 1 | // scripts/themes.js 2 | 3 | export function applyTheme(theme, albumArtImage) { 4 | // First, remove any dynamic background classes or styles 5 | clearDynamicBackground(); 6 | 7 | document.body.className = ''; // Reset any existing classes 8 | document.body.classList.add(theme); 9 | 10 | if ((theme === 'pastel' || theme === 'glass' || theme === 'gradient') && albumArtImage) { 11 | if (albumArtImage.complete) { 12 | // If the image is already loaded, apply the dynamic theme 13 | if (theme === 'pastel') { 14 | applyPastelTheme(albumArtImage); 15 | } 16 | // If the image is already loaded, apply the dynamic theme 17 | if (theme === 'gradient') { 18 | applyPastelGradientTheme(albumArtImage); 19 | } 20 | else if (theme === 'glass') { 21 | applyGlassTheme(albumArtImage); 22 | } 23 | } else { 24 | // If the image is not loaded yet, wait for it to load 25 | albumArtImage.onload = () => { 26 | if (theme === 'pastel') { 27 | applyPastelTheme(albumArtImage); 28 | } 29 | // If the image is already loaded, apply the dynamic theme 30 | if (theme === 'gradient') { 31 | applyPastelGradientTheme(albumArtImage); 32 | } 33 | 34 | else if (theme === 'glass') { 35 | applyGlassTheme(albumArtImage); 36 | } 37 | }; 38 | } 39 | } 40 | } 41 | 42 | function applyPastelTheme(imageElement) { 43 | const vibrant = new Vibrant(imageElement); 44 | vibrant.getPalette().then((palette) => { 45 | const pastelColor = palette.LightVibrant 46 | ? palette.LightVibrant.getHex() 47 | : '#ffffff'; 48 | document.documentElement.style.setProperty('--pastel-color', pastelColor); 49 | document.body.classList.add('pastel-theme'); 50 | }); 51 | } 52 | 53 | function applyGlassTheme(imageElement) { 54 | const imageUrl = imageElement.src; 55 | document.body.style.backgroundImage = `url('${imageUrl}')`; 56 | document.body.classList.add('glass-theme'); 57 | } 58 | 59 | export function clearDynamicBackground() { 60 | // Remove dynamic background styles 61 | document.body.style.backgroundImage = ''; 62 | document.body.style.backgroundColor = ''; 63 | document.body.classList.remove('glass-theme', 'pastel-theme'); 64 | document.documentElement.style.removeProperty('--pastel-color'); 65 | } 66 | 67 | export function getCurrentTheme() { 68 | return localStorage.getItem('theme') || 'light'; 69 | } 70 | 71 | export function setCurrentTheme(theme) { 72 | localStorage.setItem('theme', theme); 73 | } 74 | 75 | 76 | function applyPastelGradientTheme(imageElement) { 77 | const vibrant = new Vibrant(imageElement); 78 | vibrant.getPalette().then((palette) => { 79 | const pastelColor = palette.LightVibrant 80 | ? palette.LightVibrant.getHex() 81 | : '#ffffff'; 82 | 83 | // Define the gradient from pastelColor to dark grey (#333333) 84 | const gradient = `linear-gradient(to top right, ${pastelColor}, #333333)`; 85 | 86 | // Apply the gradient as the background 87 | document.documentElement.style.setProperty('--background-gradient', gradient); 88 | 89 | // Apply the pastel theme class if you want to style it more 90 | document.body.classList.add('pastel-gradient-theme'); 91 | }); 92 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /scripts/ui.js: -------------------------------------------------------------------------------- 1 | // scripts/ui.js 2 | 3 | import { 4 | getCurrentTheme, 5 | applyTheme, 6 | setCurrentTheme, 7 | clearDynamicBackground, 8 | } from './themes.js'; 9 | import { 10 | getPlexCredentials, 11 | savePlexCredentials, 12 | formatTime, 13 | getTimeFormat, 14 | setTimeFormat, 15 | } from './utils.js'; 16 | import { state } from './state.js'; 17 | import { fetchNowPlaying } from './api.js'; 18 | 19 | const clockElement = document.getElementById('clock'); 20 | const progressElement = document.getElementById('progress'); 21 | const currentTimeElement = document.getElementById('current-time'); 22 | const totalTimeElement = document.getElementById('total-time'); 23 | const trackTitleElement = document.getElementById('track-title'); 24 | const trackArtistElement = document.getElementById('track-artist'); 25 | const trackAlbumElement = document.getElementById('track-album'); 26 | const albumArtElement = document.getElementById('album-art'); 27 | 28 | const settingsButton = document.getElementById('settings-button'); 29 | const settingsModal = document.getElementById('settings-modal'); 30 | const closeModal = document.getElementById('close-modal'); 31 | const saveSettingsButton = document.getElementById('save-settings'); 32 | const plexIPInput = document.getElementById('plex-ip'); 33 | const plexTokenInput = document.getElementById('plex-token'); 34 | const themeSelect = document.getElementById('theme-select'); 35 | const timeFormatSelect = document.getElementById('time-format'); 36 | const enforceHttps = document.getElementById('https-toggle'); 37 | 38 | export function initializeUI() { 39 | // Event listeners for settings modal 40 | settingsButton.addEventListener('click', openSettingsModal); 41 | closeModal.addEventListener('click', closeSettingsModal); 42 | window.addEventListener('click', outsideClick); 43 | saveSettingsButton.addEventListener('click', saveSettings); 44 | 45 | // Start the progress bar updater 46 | setInterval(updateProgressBar, 200); // Update every 200ms 47 | } 48 | 49 | export function updateClock() { 50 | const now = new Date(); 51 | const hours = now.getHours(); 52 | const minutes = now.getMinutes().toString().padStart(2, '0'); 53 | 54 | const timeFormat = getTimeFormat(); 55 | if (timeFormat === '24h') { 56 | clockElement.innerText = `${hours.toString().padStart(2, '0')}:${minutes}`; 57 | } else { 58 | const ampm = hours >= 12 ? 'PM' : 'AM'; 59 | const displayHours = hours % 12 || 12; 60 | clockElement.innerText = `${displayHours}:${minutes} ${ampm}`; 61 | } 62 | } 63 | 64 | function updateProgressBar() { 65 | if (state.isPlaying) { 66 | const now = Date.now(); 67 | const elapsedTime = now - state.lastUpdate; 68 | state.currentOffset += elapsedTime; 69 | state.lastUpdate = now; 70 | 71 | const adjustedOffset = Math.min(state.currentOffset, state.totalDuration); 72 | 73 | const progressPercentage = (adjustedOffset / state.totalDuration) * 100; 74 | progressElement.style.width = progressPercentage + '%'; 75 | currentTimeElement.innerText = formatTime(adjustedOffset); 76 | 77 | if (adjustedOffset >= state.totalDuration) { 78 | state.isPlaying = false; 79 | resetNowPlaying(); 80 | } 81 | } 82 | } 83 | 84 | export function updateNowPlayingUI(mediaInfo) { 85 | const { 86 | mediaId, 87 | title, 88 | artist, 89 | album, 90 | albumYear, 91 | coverUrl, 92 | plexIP, 93 | plexToken, 94 | viewOffset, 95 | duration, 96 | } = mediaInfo; 97 | const useHttps = localStorage.getItem('EnforceHTTPS') === 'true'; 98 | const protocol = useHttps ? 'https' : 'http'; 99 | const imageUrl = `${protocol}://${plexIP}:32400${coverUrl}?X-Plex-Token=${plexToken}`; 100 | 101 | trackTitleElement.innerText = title; 102 | trackArtistElement.innerText = artist; 103 | trackAlbumElement.innerText = albumYear ? `${album} (${albumYear})` : album; 104 | 105 | albumArtElement.crossOrigin = 'Anonymous'; // Important for CORS -> Fuck CORS, im not a web dev 106 | albumArtElement.src = imageUrl; 107 | 108 | // Update total time 109 | totalTimeElement.innerText = formatTime(duration); 110 | 111 | // Sync progress only if media has changed or client is behind server by more than 2 seconds 112 | const drift = viewOffset - state.currentOffset; 113 | if (state.lastMediaId !== mediaId || drift > 2000) { 114 | state.lastMediaId = mediaId; 115 | state.currentOffset = viewOffset; 116 | state.totalDuration = duration; 117 | state.lastUpdate = Date.now(); 118 | } else { 119 | // Do not adjust if the server's viewOffset is behind or within 2 seconds ahead 120 | // This prevents the progress bar from skipping back 121 | } 122 | 123 | state.isPlaying = true; 124 | 125 | // Apply theme when album art loads 126 | albumArtElement.onload = () => { 127 | const theme = getCurrentTheme(); 128 | if (theme === 'pastel' || theme === 'glass' || theme === 'gradient') { 129 | applyTheme(theme, albumArtElement); 130 | } else { 131 | clearDynamicBackground(); 132 | applyTheme(theme); 133 | } 134 | }; 135 | } 136 | 137 | export function useHttps() { 138 | // Get the EnforceHTTPS setting from localStorage and return it as a boolean value 139 | return localStorage.getItem('EnforceHTTPS') === 'true'; 140 | } 141 | 142 | export function resetNowPlaying() { 143 | trackTitleElement.innerText = 'Nothing is currently playing.'; 144 | trackArtistElement.innerText = ''; 145 | trackAlbumElement.innerText = ''; 146 | albumArtElement.src = 'Assets/Images/loadingcat.webp'; // Default image 147 | progressElement.style.width = '0%'; 148 | currentTimeElement.innerText = '0:00'; 149 | totalTimeElement.innerText = '0:00'; 150 | state.isPlaying = false; 151 | 152 | // Reset background for dynamic themes 153 | clearDynamicBackground(); 154 | 155 | // Apply the current theme without dynamic backgrounds 156 | applyTheme(getCurrentTheme()); 157 | } 158 | 159 | // Settings Modal Functions 160 | function openSettingsModal() { 161 | settingsModal.style.display = 'block'; 162 | // Populate inputs with current values 163 | const { plexToken, plexIP } = getPlexCredentials(); 164 | plexIPInput.value = plexIP || ''; 165 | plexTokenInput.value = plexToken || ''; 166 | themeSelect.value = getCurrentTheme(); 167 | timeFormatSelect.value = getTimeFormat(); 168 | enforceHttps.checked = localStorage.getItem('EnforceHTTPS') === 'true'; 169 | 170 | // Apply theme to modal 171 | settingsModal.className = `modal ${getCurrentTheme()}`; 172 | } 173 | 174 | function closeSettingsModal() { 175 | settingsModal.style.display = 'none'; 176 | } 177 | 178 | function outsideClick(event) { 179 | if (event.target === settingsModal) { 180 | settingsModal.style.display = 'none'; 181 | } 182 | } 183 | 184 | function saveSettings() { 185 | const plexIP = plexIPInput.value.trim(); 186 | const plexToken = plexTokenInput.value.trim(); 187 | const selectedTheme = themeSelect.value; 188 | const selectedTimeFormat = timeFormatSelect.value; 189 | const useHttps = enforceHttps.checked; 190 | 191 | if (plexIP && plexToken) { 192 | savePlexCredentials(plexToken, plexIP); 193 | } 194 | 195 | setCurrentTheme(selectedTheme); 196 | 197 | // Apply the theme immediately 198 | const theme = selectedTheme; 199 | if (theme === 'pastel' || theme === 'glass') { 200 | applyTheme(theme, albumArtElement); 201 | } else { 202 | applyTheme(theme); 203 | } 204 | 205 | setTimeFormat(selectedTimeFormat); 206 | localStorage.setItem('EnforceHTTPS', useHttps); 207 | updateClock(); 208 | 209 | settingsModal.style.display = 'none'; 210 | fetchNowPlaying(); // Refresh now playing info 211 | } 212 | -------------------------------------------------------------------------------- /styles/style.css: -------------------------------------------------------------------------------- 1 | /* styles/style.css */ 2 | 3 | /* Reset default styles */ 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | } 9 | 10 | /* Variables */ 11 | :root { 12 | --pastel-color: #ffd1dc; /* Default pastel color */ 13 | } 14 | 15 | /* Base Styles */ 16 | body { 17 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 18 | display: flex; 19 | flex-direction: column; 20 | height: 100vh; 21 | transition: background 0.3s ease-in-out; 22 | background-size: cover; 23 | background-position: center; 24 | background-repeat: no-repeat; 25 | } 26 | 27 | header { 28 | display: flex; 29 | justify-content: flex-end; 30 | padding: 20px; 31 | font-size: 18px; 32 | color: #555; 33 | } 34 | 35 | main { 36 | flex: 1; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | } 42 | 43 | .now-playing { 44 | text-align: center; 45 | width: 90%; 46 | max-width: 400px; 47 | } 48 | 49 | .album-art img { 50 | width: 100%; 51 | border-radius: 8px; 52 | box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); 53 | } 54 | 55 | .track-details { 56 | margin-top: 20px; 57 | } 58 | 59 | .progress-container { 60 | margin-top: 20px; 61 | width: 100%; 62 | } 63 | 64 | .progress-bar { 65 | width: 100%; 66 | height: 4px; 67 | background-color: #e5e5e5; 68 | border-radius: 2px; 69 | overflow: hidden; 70 | } 71 | 72 | .progress { 73 | height: 100%; 74 | background-color: #007aff; 75 | width: 0%; 76 | transition: width 0.1s linear; 77 | } 78 | 79 | .time-info { 80 | display: flex; 81 | justify-content: space-between; 82 | margin-top: 5px; 83 | font-size: 14px; 84 | color: #555; 85 | } 86 | 87 | #clock { 88 | font-size: 18px; 89 | } 90 | 91 | #settings-button { 92 | position: fixed; 93 | bottom: 20px; 94 | right: 20px; 95 | background: none; 96 | border: none; 97 | cursor: pointer; 98 | } 99 | 100 | #settings-button img { 101 | width: 30px; 102 | height: 30px; 103 | } 104 | 105 | .modal { 106 | display: none; 107 | position: fixed; 108 | z-index: 10; 109 | left: 0; 110 | top: 0; 111 | width: 100%; 112 | height: 100%; 113 | overflow: auto; 114 | background-color: rgba(0,0,0,0.5); 115 | } 116 | 117 | .modal-content { 118 | background-color: #fff; 119 | margin: 10% auto; 120 | padding: 20px; 121 | border-radius: 8px; 122 | width: 80%; 123 | max-width: 400px; 124 | position: relative; 125 | } 126 | 127 | #close-modal { 128 | position: absolute; 129 | top: 10px; 130 | right: 20px; 131 | font-size: 28px; 132 | font-weight: bold; 133 | cursor: pointer; 134 | } 135 | 136 | .modal-content h2 { 137 | margin-bottom: 20px; 138 | } 139 | 140 | .modal-content label { 141 | display: block; 142 | margin-top: 10px; 143 | } 144 | 145 | .modal-content input, .modal-content select { 146 | width: 100%; 147 | padding: 8px; 148 | margin-top: 5px; 149 | border-radius: 4px; 150 | border: 1px solid #ccc; 151 | } 152 | 153 | #save-settings { 154 | margin-top: 20px; 155 | padding: 10px; 156 | width: 100%; 157 | background-color: #007aff; 158 | color: #fff; 159 | border: none; 160 | border-radius: 4px; 161 | cursor: pointer; 162 | } 163 | 164 | /* Theme Styles */ 165 | 166 | /* Light Theme */ 167 | body.light { 168 | background-color: #fff; 169 | color: #000; 170 | } 171 | 172 | body.light .track-details h2 { 173 | color: #555; 174 | } 175 | 176 | body.light .track-details h3 { 177 | color: #777; 178 | } 179 | 180 | body.light .progress-bar { 181 | background-color: #e5e5e5; 182 | } 183 | 184 | body.light .progress { 185 | background-color: #007aff; 186 | } 187 | 188 | body.light .time-info { 189 | color: #555; 190 | } 191 | 192 | body.light .modal-content { 193 | background-color: #fff; 194 | color: #000; 195 | } 196 | 197 | /* Reduced drop shadow in Light mode */ 198 | body.light .track-details h1, 199 | body.light .track-details h2, 200 | body.light .track-details h3, 201 | body.light #clock { 202 | text-shadow: 0 0 2px rgba(0, 0, 0, 0.3); 203 | } 204 | 205 | /* Dark Theme */ 206 | body.dark { 207 | background-color: #1c1c1c; 208 | color: #fff; 209 | } 210 | 211 | body.dark header { 212 | color: #fff; 213 | } 214 | 215 | body.dark .track-details h2, 216 | body.dark .track-details h3, 217 | body.dark .time-info { 218 | color: #ccc; 219 | } 220 | 221 | body.dark .progress-bar { 222 | background-color: #333; 223 | } 224 | 225 | body.dark .progress { 226 | background-color: #007aff; 227 | } 228 | 229 | body.dark .modal-content { 230 | background-color: #333; 231 | color: #fff; 232 | } 233 | 234 | /* Dark (OLED) Theme */ 235 | body.dark-oled { 236 | background-color: #000; 237 | color: #fff; 238 | } 239 | 240 | body.dark-oled header { 241 | color: #fff; 242 | } 243 | 244 | body.dark-oled .track-details h2, 245 | body.dark-oled .track-details h3, 246 | body.dark-oled .time-info { 247 | color: #ccc; 248 | } 249 | 250 | body.dark-oled .progress-bar { 251 | background-color: #222; 252 | } 253 | 254 | body.dark-oled .progress { 255 | background-color: #007aff; 256 | } 257 | 258 | body.dark-oled .modal-content { 259 | background-color: #222; 260 | color: #fff; 261 | } 262 | 263 | /* Bubblegum Theme */ 264 | body.bubblegum { 265 | background-color: #ff69b4; 266 | color: #fff; 267 | font-family: "Comic Sans MS", cursive, sans-serif; 268 | } 269 | 270 | body.bubblegum .track-details h2, 271 | body.bubblegum .track-details h3, 272 | body.bubblegum .time-info { 273 | color: #fff; 274 | } 275 | 276 | body.bubblegum .progress-bar { 277 | background-color: #ff1493; 278 | } 279 | 280 | body.bubblegum .progress { 281 | background-color: #ff6ec7; 282 | } 283 | 284 | body.bubblegum #clock { 285 | color: #fff; 286 | } 287 | 288 | body.bubblegum header { 289 | color: #fff; 290 | } 291 | 292 | body.bubblegum button, 293 | body.bubblegum input, 294 | body.bubblegum select { 295 | font-family: "Comic Sans MS", cursive, sans-serif; 296 | } 297 | 298 | body.bubblegum .modal-content { 299 | background-color: #ff69b4; 300 | color: #fff; 301 | } 302 | 303 | body.bubblegum { 304 | cursor: url("data:image/svg+xml;utf8,❤️") 16 0, auto; 305 | } 306 | 307 | /* Pastel Theme */ 308 | body.pastel { 309 | background-color: var(--pastel-color); 310 | color: #fff; 311 | } 312 | 313 | body.pastel .track-details h2, 314 | body.pastel .track-details h3, 315 | body.pastel .time-info { 316 | color: rgba(255, 255, 255, 0.9); 317 | } 318 | 319 | body.pastel .progress-bar { 320 | background-color: rgba(255, 255, 255, 0.3); 321 | } 322 | 323 | body.pastel .progress { 324 | background-color: rgba(255, 255, 255, 0.7); 325 | } 326 | 327 | body.pastel #clock { 328 | color: #fff; 329 | } 330 | 331 | body.pastel header { 332 | color: #fff; 333 | } 334 | 335 | body.pastel .modal-content { 336 | background-color: rgba(0, 0, 0, 0.7); 337 | color: #fff; 338 | } 339 | 340 | /* Ensure text is legible over any background */ 341 | body.pastel .track-details h1, 342 | body.pastel .track-details h2, 343 | body.pastel .track-details h3, 344 | body.pastel #clock { 345 | text-shadow: 0 0 3px rgba(0, 0, 0, 0.5); 346 | } 347 | 348 | /* Glass Theme */ 349 | body.glass { 350 | position: relative; 351 | overflow: hidden; 352 | color: #fff; 353 | } 354 | 355 | body.glass::before { 356 | content: ''; 357 | position: absolute; 358 | top: 0; 359 | right: 0; 360 | bottom: 0; 361 | left: 0; 362 | background: inherit; 363 | filter: blur(20px) brightness(0.7); 364 | transform: scale(1.1); 365 | z-index: -1; 366 | } 367 | 368 | body.glass .now-playing, 369 | body.glass header { 370 | position: relative; 371 | z-index: 1; 372 | } 373 | 374 | body.glass .track-details h1, 375 | body.glass .track-details h2, 376 | body.glass .track-details h3, 377 | body.glass #clock { 378 | text-shadow: 0 0 3px rgba(0, 0, 0, 0.5); 379 | } 380 | 381 | body.glass .track-details h2, 382 | body.glass .track-details h3, 383 | body.glass .time-info { 384 | color: rgba(255, 255, 255, 0.9); 385 | } 386 | 387 | body.glass .progress-bar { 388 | background-color: rgba(255, 255, 255, 0.3); 389 | } 390 | 391 | body.glass .progress { 392 | background-color: rgba(255, 255, 255, 0.7); 393 | } 394 | 395 | body.glass #clock { 396 | color: #fff; 397 | } 398 | 399 | body.glass header { 400 | color: #fff; 401 | } 402 | 403 | body.glass .modal-content { 404 | background-color: rgba(0, 0, 0, 0.7); 405 | color: #fff; 406 | } 407 | 408 | /* Add semi-transparent overlay behind content for Glass theme */ 409 | body.glass .now-playing { 410 | background-color: rgba(0, 0, 0, 0.5); 411 | padding: 20px; 412 | border-radius: 8px; 413 | } 414 | 415 | 416 | /* Gradient Theme */ 417 | .pastel-gradient-theme { 418 | background: var(--background-gradient); 419 | background-size: cover; 420 | background-repeat: no-repeat; 421 | height: 100vh; /* Ensures it covers the full height of the page */ 422 | } 423 | 424 | body.gradient .track-details h2, 425 | body.gradient .track-details h3, 426 | body.gradient .time-info { 427 | color: rgba(255, 255, 255, 0.9); 428 | } 429 | 430 | body.gradient .progress-bar { 431 | background-color: rgba(255, 255, 255, 0.3); 432 | } 433 | 434 | body.gradient .progress { 435 | background-color: rgba(255, 255, 255, 0.7); 436 | } 437 | 438 | body.gradient #clock { 439 | color: #fff; 440 | } 441 | 442 | body.gradient header { 443 | color: #fff; 444 | } 445 | 446 | body.gradient .modal-content { 447 | background-color: rgba(0, 0, 0, 0.7); 448 | color: #fff; 449 | } 450 | 451 | /* Ensure text is legible over any background */ 452 | body.gradient .track-details h1, 453 | body.gradient .track-details h2, 454 | body.gradient .track-details h3, 455 | body.gradient #clock { 456 | text-shadow: 0 0 3px rgba(0, 0, 0, 0.5); 457 | } 458 | --------------------------------------------------------------------------------