├── 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 | 
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 |
12 |
13 |
14 |
15 |
16 |
![Album Art]()
17 |
18 |
19 |
Track Title
20 | Artist Name
21 | Album Name (Year)
22 |
23 |
24 |
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 |
--------------------------------------------------------------------------------