//////////////// // PARAMETERS // //////////////// const queryString = window.location.search; const urlParams = new URLSearchParams(queryString); //////////////// // CONSTANTS // //////////////// const REQUIRED_VERSION = '0.0.4'; const SMTC_BRIDGE_DOWNLOAD_URL = 'https://github.com/nuttylmao/smtc-bridge/releases'; let VersionChecked = false; /////////////////// // PAGE ELEMENTS // /////////////////// const mainContainer = document.getElementById('main-container'); ///////////// // OPTIONS // ///////////// const smtcBridgeAddress = urlParams.get('smtcBridgeAddress') || '127.0.0.1'; const smtcBridgePort = urlParams.get('smtcBridgePort') || '5000'; const theme = urlParams.get('theme') || 'standard'; const font = urlParams.get("font") || ""; const fontSize = GetIntParam("fontSize", 20); const maxWidth = GetIntParam("maxWidth", 500); const verticalAlignment = urlParams.get('verticalAlignment') || 'align-to-center'; const textAlignment = urlParams.get('textAlignment') || 'left'; const useCustomColors = GetBooleanParam("useCustomColors", false); const color1 = urlParams.get('color1') || '#ffffff'; const color2 = urlParams.get('color2') || '#1d1d1d'; const includedApplications = urlParams.get('includedApplications') || ''; const excludedApplications = urlParams.get('excludedApplications') || ''; const showAlbumArt = GetBooleanParam("showAlbumArt", true); const showProgressBar = GetBooleanParam("showProgressBar", true); const swapArtistTrack = GetBooleanParam("swapArtistTrack", false); const showPrimary = GetBooleanParam("showPrimary", true); const showSecondary = GetBooleanParam("showSecondary", true); const autoHide = GetBooleanParam("autoHide", false); const displayDuration = GetIntParam("displayDuration", 5); const showAnimation = urlParams.get('showAnimation') || 'slide-in-from-bottom'; const hideAnimation = urlParams.get('hideAnimation') || 'slide-out-bottom'; ///////////////// // GLOBAL VARS // ///////////////// let smtcBridgePopup = null; let versionCheckPopup = null; let errorPopup = null; let skipVersionCheck = false; let CurrentPlaybackStatus; let CurrentSongKey; let hideTimeout = null; //////////////// // PAGE SETUP // //////////////// // Set fonts for the widget document.body.style.fontFamily = `'${font}'`; document.body.style.fontSize = `${fontSize}px`; // Set album art visibility if (showAlbumArt) document.documentElement.style.setProperty('--show-album-art', ``); else document.documentElement.style.setProperty('--show-album-art', `none`); // Set text alignment document.documentElement.style.setProperty('--text-alignment', `${textAlignment}`); // Set vertical alignment switch (verticalAlignment) { case 'align-to-top': mainContainer.style.alignItems = 'flex-start'; break; case 'align-to-center': mainContainer.style.alignItems = 'center'; break; case 'align-to-bottom': mainContainer.style.alignItems = 'flex-end'; break; } //////////////// // LOAD THEME // //////////////// // Load the theme async function LoadTheme() { // Check if the theme inherits from a base theme let baseTheme = theme; try { const response = await fetch(`./themes/${theme}/inherits.json`); if (response.ok) { const config = await response.json(); baseTheme = config['base-theme']; } } catch (e) { // No inheritance file found, assume it is a base theme } // Fetch the HTML structure from the base theme folder const response = await fetch(`./themes/${baseTheme}/index.html`); const html = await response.text(); mainContainer.innerHTML = html; // Swap the CSS file const link = document.getElementById('theme-style'); link.href = `./themes/${baseTheme}/style.css`; // Load the theme-specific JS // We remove the old script tag and add a new one const oldScript = document.getElementById('theme-script'); if (oldScript) oldScript.remove(); const script = document.createElement('script'); script.src = `./themes/${baseTheme}/script.js`; script.id = 'theme-script'; document.body.appendChild(script); // If the base theme != theme, that means this is a variant, so load the variant's CSS if (baseTheme != theme) { // Load the variant CSS const baseLink = document.createElement('link'); baseLink.rel = 'stylesheet'; baseLink.className = 'theme-style'; // Use class for easy batch removal baseLink.href = `./themes/${theme}/style.css`; document.head.appendChild(baseLink); window.ThemeVariant = theme; } } LoadTheme(); ///////////////// // NOW PLAYING // ///////////////// async function FetchMedia() { try { const response = await fetch(`http://${smtcBridgeAddress}:${smtcBridgePort}/now-playing`); const data = await response.json(); // Remove the SMTC Bridge popup if it's on screen CloseSMTCBridgePopup(); // Check for errors in the response if (data.error) { ShowErrorPopup(data.error); return; } else { CloseErrorPopup(); } // Check the SMTC Bridge version if (!VersionChecked) { CheckSMTCBridgeVersion(data.app_version); VersionChecked = true; } // Update the UI with the received data // console.log(data); UpdatePlayerState(data); } catch (error) { console.error("Failed to connect to Flask media server:", error); // Show a popup to instruct the user to install SMTC Bridge ShowWaitingForSMTCBridgePopup(); } } window.addEventListener('DOMContentLoaded', () => { setTimeout(() => { // Start polling every 1000 milliseconds setInterval(FetchMedia, 1000); // Run once immediately on script load FetchMedia(); }, 100); }); //////////// // POPUPS // //////////// function ShowWaitingForSMTCBridgePopup() { const newPopup = SplashscreenPopup( '/.common/resources/smtc-bridge-icon.png', 'Waiting for SMTC Bridge', 'Please launch SMTC Bridge', '', // Attribute text 'linear-gradient(0deg, #111111 0%, #11001f 100%)', { text: 'Download', action: () => { window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank"); } } ); if (newPopup) { smtcBridgePopup = newPopup; } } function ShowSMTCBridgeUpdateRequiredPopup(installedVersion) { const newPopup = SplashscreenPopup( '/.common/resources/smtc-bridge-icon.png', 'Update Required', 'Your version of SMTC Bridge is out of date.', `Installed Version: ${installedVersion}
New Version: ${REQUIRED_VERSION}`, 'linear-gradient(0deg, #1b0005 0%, #4f000d 100%)', { text: 'Download', action: () => { window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank"); } } ); if (newPopup) { versionCheckPopup = newPopup; } } function ShowSMTCBridgeUpdateAvailablePopup(installedVersion) { const newPopup = SplashscreenPopup( '/.common/resources/smtc-bridge-icon.png', 'Update Available', 'A new version of SMTC Bridge is available.', `Installed Version: ${installedVersion}
New Version: ${REQUIRED_VERSION}`, 'linear-gradient(0deg, #2a004f 0%, #4f2675 100%)', { text: 'Download', action: () => { window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank"); } } ); if (newPopup) { versionCheckPopup = newPopup; } setTimeout(() => { CloseVersionCheckPopup(); }, 10000); } function ShowErrorPopup(errorMessage) { const newPopup = SplashscreenPopup( '/.common/resources/smtc-bridge-icon.png', 'SMTC Bridge Error', `I'm a shit programmer and I fucked something up.`, `Error: ${errorMessage}`, 'linear-gradient(0deg, #1b0005 0%, #4f000d 100%)' ); if (newPopup) { errorPopup = newPopup; } } function CloseErrorPopup() { if (errorPopup) { errorPopup.close(); errorPopup = null; } } function CloseSMTCBridgePopup() { if (smtcBridgePopup) { smtcBridgePopup.close(); smtcBridgePopup = null; } } function CloseVersionCheckPopup() { if (versionCheckPopup) { versionCheckPopup.close(); versionCheckPopup = null; } } ////////////////////// // HELPER FUNCTIONS // ////////////////////// function CheckSMTCBridgeVersion(installedVersion) { // Check that the server/client are on the same SMTC Bridge version // const VersionStatus = VersionCheck(REQUIRED_VERSION, installedVersion); const versionStatus = VersionCheck(REQUIRED_VERSION, installedVersion); if (skipVersionCheck) return; switch (versionStatus) { case 'incompatible': ShowSMTCBridgeUpdateRequiredPopup(installedVersion); skipVersionCheck = false; break; case 'soft-warning': ShowSMTCBridgeUpdateAvailablePopup(installedVersion); skipVersionCheck = true; break; default: CloseVersionCheckPopup(); break; } } // Each theme must implement the following async function UpdatePlayerState(data) { // Parse and clean settings arrays const includedList = includedApplications ? includedApplications.split(',').map(app => app.trim().toLowerCase()).filter(Boolean) : []; const excludedList = excludedApplications ? excludedApplications.split(',').map(app => app.trim().toLowerCase()).filter(Boolean) : []; // Filter out any sessions belonging to excluded apps const validSessions = data.sessions.filter(s => { const appId = (s.source_app_id || "").toLowerCase(); // Check if the source app ID matches any exclusion entry const isExcluded = excludedList.some(excluded => appId.includes(excluded)); return !isExcluded; }); let targetSession = null; // Priority Check: If user specified included apps, hunt for them in exact order if (includedList.length > 0) { // Step 1: Look through the included list for an app that is CURRENTLY PLAYING for (const targetApp of includedList) { targetSession = validSessions.find(s => { const matchesApp = (s.source_app_id || "").toLowerCase().includes(targetApp); const isPlaying = s.playback_info && s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING; return matchesApp && isPlaying; }); if (targetSession) break; } // Step 2: If none of the included apps are playing, fall back to ANY session in the included list (even if paused) if (!targetSession) { for (const targetApp of includedList) { targetSession = validSessions.find(s => (s.source_app_id || "").toLowerCase().includes(targetApp) ); if (targetSession) break; } } } else { // Fallback when no included list is provided: // Priority 1: Check Windows' current focused session ID first if (data.current_session_id) { targetSession = validSessions.find(s => s.source_app_id === data.current_session_id); } // Priority 2: If the current session isn't available/valid, find any session that is currently playing if (!targetSession || targetSession.playback_info.PlaybackStatus !== PlaybackStatus.PLAYING) { const playingSession = validSessions.find(s => s.playback_info && s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING); if (playingSession) { targetSession = playingSession; } } // Priority 3: Ultimate fallback to the first valid session available if nothing else matched if (!targetSession && validSessions.length > 0) { targetSession = validSessions[0]; } } // If a target session was found, update the state of the widget if (targetSession) { // Extract the relevant properties from the session const playbackInfo = targetSession.playback_info; const mediaProps = targetSession.media_properties; const timelineProps = targetSession.timeline_properties; // Calcualte an accent color const accentColorPalette = await GetAccentPalette(mediaProps.Thumbnail ?? './images/placeholder.png'); // 1. Check if playback status has changed and update visibility accordingly if (playbackInfo.PlaybackStatus !== CurrentPlaybackStatus) { if (playbackInfo.PlaybackStatus === PlaybackStatus.PLAYING) SetVisibility(true); else SetVisibility(false); CurrentPlaybackStatus = playbackInfo.PlaybackStatus; } // 2. Check if the track name/artist have changed - this is our indicator that the next track has loaded // Only proceed if the player state is actively playing audio if (CurrentPlaybackStatus == PlaybackStatus.PLAYING) { const newTrackKey = `${mediaProps.Title}-${mediaProps.Artist}-${mediaProps.Thumbnail}`; if (newTrackKey !== CurrentSongKey) { ChangeTrack(mediaProps, accentColorPalette); // Now trigger your cross-fade logic here! CurrentSongKey = newTrackKey; // Update the tracker with the string key } } // 3. Update the progress info if (timelineProps) { // Parse the Windows timestamp into a JavaScript time object const lastUpdateAnchor = Date.parse(timelineProps.LastUpdatedTime.replace(' ', 'T')); // Calculate the drift (i.e. how many milliseconds have passed since Windows last updated) const driftMs = Date.now() - lastUpdateAnchor; // Add that drift to the reported Position // Only add drift if the status is PLAYING const isPlaying = (targetSession.playback_info.PlaybackStatus === PlaybackStatus.PLAYING); const currentPositionMs = isPlaying && timelineProps.EndTime > 0 ? timelineProps.Position + driftMs : timelineProps.Position; SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette); } } else { SetVisibility(false); } } function SetVisibility(visible) { // Always clear any pending hide timers whenever we change visibility if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } if (visible) { mainWrapper.style.animation = `${showAnimation} 0.5s ease-out forwards`; // Only set a new timer if autoHide is enabled if (autoHide) { hideTimeout = setTimeout(() => { SetVisibility(false); }, displayDuration * 1000); } } else { mainWrapper.style.animation = `${hideAnimation} 0.5s ease-out forwards`; } } // MARQUEE LOGIC // This is a more advanced marquee implementation that calculates the overflow distance and adjusts the scroll speed accordingly const labelData = new Map(); // Adjust this value to change scroll speed (pixels per second) const SCROLL_SPEED_PX_PER_SEC = 40; function SetLabelText(elementId, text) { const label = document.getElementById(elementId); if (!label) return; labelData.set(elementId, text); ApplyMarquee(label, text); // Keep it responsive on container resize if (!label._resizeObserver) { label._resizeObserver = new ResizeObserver(() => { const currentText = labelData.get(elementId); if (currentText) ApplyMarquee(label, currentText); }); label._resizeObserver.observe(label); } } function ApplyMarquee(label, text) { // 1. Render single span to measure dimensions label.classList.remove('is-overflowing'); label.innerHTML = ``; label.querySelector('.scroll-content').textContent = text; const scrollContent = label.querySelector('.scroll-content'); const textWidth = scrollContent.scrollWidth; const containerWidth = label.clientWidth; const overflowDistance = textWidth - containerWidth; // 2. Check if text exceeds container if (overflowDistance > 0) { // Calculate travel distance in percentage relative to scrollContent's width const distancePercent = (overflowDistance / textWidth) * 100; // Compute duration to keep speed consistent regardless of length // We multiply travel time by 2 (for both directions) plus 3 seconds for pauses const travelTime = overflowDistance / SCROLL_SPEED_PX_PER_SEC; const totalDuration = (travelTime * 2) + 3; // Pass calculated variables to CSS scrollContent.style.setProperty('--scroll-distance', `-${distancePercent}%`); label.style.setProperty('--marquee-duration', `${totalDuration}s`); label.classList.add('is-overflowing'); } }