diff --git a/.common/enums/smtc-enums.js b/.common/enums/smtc-enums.js
new file mode 100644
index 0000000..365d7e0
--- /dev/null
+++ b/.common/enums/smtc-enums.js
@@ -0,0 +1,28 @@
+// ==========================================
+// WINDOWS SMTC API CONSTANTS (IMMUTABLE)
+// ==========================================
+
+// Playback Status (session.playback_info.PlaybackStatus)
+const PlaybackStatus = Object.freeze({
+ CLOSED: 0, // Engine uninitialized or empty
+ OPENED: 1, // Pipeline loaded but idling
+ CHANGING: 2, // Buffering, track skipping, or seeking
+ STOPPED: 3, // Track queued but fully stopped (at 0:00)
+ PLAYING: 4, // Audio actively streaming (Run dead reckoning)
+ PAUSED: 5 // Audio frozen (Halt dead reckoning)
+});
+
+// Playback Type (session.playback_info.PlaybackType)
+const PlaybackType = Object.freeze({
+ UNKNOWN: 0, // Generic audio wrapper
+ MUSIC: 1, // Pure audio pipeline (Spotify, iTunes, etc.)
+ VIDEO: 2, // Visual media feed (Chrome/Firefox YouTube/Twitch tabs)
+ IMAGE: 3 // Static slideshow presentation hook
+});
+
+// Auto Repeat Mode (session.playback_info.AutoRepeatMode)
+const AutoRepeatMode = Object.freeze({
+ NONE: 0, // Plays queue through and terminates
+ TRACK: 1, // Single active song looping indefinitely
+ LIST: 2 // Parent playlist/album looping indefinitely
+});
\ No newline at end of file
diff --git a/now-playing/index.html b/now-playing/index.html
new file mode 100644
index 0000000..cd5baf3
--- /dev/null
+++ b/now-playing/index.html
@@ -0,0 +1,46 @@
+
+
+
+ Now Playing
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/now-playing/launch-bridge.bat b/now-playing/launch-bridge.bat
new file mode 100644
index 0000000..e21a58c
--- /dev/null
+++ b/now-playing/launch-bridge.bat
@@ -0,0 +1,4 @@
+@echo off
+echo Starting Media Bridge...
+python smtc-bridge.py
+pause
\ No newline at end of file
diff --git a/now-playing/script.js b/now-playing/script.js
new file mode 100644
index 0000000..7a03123
--- /dev/null
+++ b/now-playing/script.js
@@ -0,0 +1,271 @@
+////////////////
+// PARAMETERS //
+////////////////
+
+const queryString = window.location.search;
+const urlParams = new URLSearchParams(queryString);
+
+
+///////////////////
+// PAGE ELEMENTS //
+///////////////////
+
+const standardLayout = document.getElementById('standard-layout');
+const albumArtContainer = document.getElementById('album-art-container');
+const songInfoContainer = document.getElementById('song-info-container');
+const albumArtLayer = document.getElementById('album-art-layer');
+const albumArtTransition = document.getElementById('album-art-transition-layer');
+const backgroundLayer = document.getElementById('background-layer');
+const backgroundTransitionLayer = document.getElementById('background-transition-layer');
+const trackLabel = document.getElementById('track-label');
+const artistLabel = document.getElementById('artist-label');
+const progressContainer = document.getElementById('progress-container');
+const progressBarFill = document.getElementById('progress-bar-fill');
+const currentTimeLabel = document.getElementById('current-time');
+const durationLabel = document.getElementById('duration');
+
+/////////////////
+// GLOBAL VARS //
+/////////////////
+
+let CurrentPlaybackStatus;
+let CurrentSong;
+
+/////////////
+// OPTIONS //
+/////////////
+
+const theme = urlParams.get('theme') || 'standard';
+const font = urlParams.get("font") || "";
+const fontSize = GetIntParam("fontSize", 20);
+const width = GetIntParam("width", 500);
+const albumArt = urlParams.get('albumArt') || 'show';
+const showProgressBar = GetBooleanParam("showProgressBar", true);
+
+const targetApplication = urlParams.get('targetApplication') || '';
+const autoHide = GetBooleanParam("autoHide", false);
+const displayDuration = GetIntParam("displayDuration", 5);
+const showAnimation = urlParams.get('showAnimation') || 'fade';
+const hideAnimation = urlParams.get('hideAnimation') || 'fade';
+
+
+
+////////////////
+// PAGE SETUP //
+////////////////
+
+// Set fonts for the widget
+document.body.style.fontFamily = font;
+document.body.style.fontSize = `${fontSize}px`;
+standardLayout.style.width = `${width}px`;
+
+// Set album art style
+switch (albumArt)
+{
+ case 'none':
+ albumArtContainer.style.display = 'none';
+ break;
+ case 'show':
+ albumArtContainer.style.display = '';
+ break;
+ case 'spinny':
+ break;
+}
+
+// Set progress bar visibility
+if (!showProgressBar)
+ progressContainer.style.display = 'none';
+
+
+/////////////////
+// NOW PLAYING //
+/////////////////
+
+async function FetchMedia() {
+ try {
+ const response = await fetch('http://localhost:5000/now-playing');
+ const data = await response.json();
+
+ // Update the UI with the received data
+ console.log(data);
+ UpdateUI(data);
+
+ } catch (error) {
+ console.error("Failed to connect to Flask media server:", error);
+ }
+}
+
+function UpdateUI(data) {
+ // Check if the user has provided a specific app in the settings
+ const isFiltering = targetApplication && targetApplication.trim() !== "";
+
+ // Decide which ID to look for
+ const sessionToFind = isFiltering ? targetApplication : data.current_session_id;
+
+ // Now perform the find
+ let targetSession = data.sessions.find(s => {
+ // If filtering, check if the source_app_id matches the user's string
+ if (isFiltering) {
+ return s.source_app_id.toLowerCase() === targetApplication.toLowerCase();
+ }
+ // Otherwise, match the system's current active session ID
+ return s.source_app_id === sessionToFind;
+ });
+
+ if (targetSession) {
+ // Extract the relevant properties from the session
+ const playbackInfo = targetSession.playback_info;
+ const mediaProps = targetSession.media_properties;
+ const timelineProps = targetSession.timeline_properties;
+
+ // 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;
+ }
+
+ // Save current media properties - we can use this to detect track changes and trigger transition animations in the future
+ const newTrackKey = `${mediaProps.Title}|${mediaProps.Artist}`;
+ if (newTrackKey !== CurrentSong) {
+ ChangeTrack(mediaProps); // Now trigger your cross-fade logic here!
+ CurrentSong = newTrackKey; // Update the tracker with the string key
+ }
+
+ if (timelineProps)
+ {
+ // Parse the Windows timestamp into a JavaScript time object
+ const lastUpdateAnchor = Date.parse(timelineProps.LastUpdatedTime.replace(' ', 'T'));
+
+ // Calculate the drift: how many milliseconds have passed since Windows last spoke?
+ const driftMs = Date.now() - lastUpdateAnchor;
+
+ // Add that drift to the reported Position
+ // Only add drift if the status is 4 (Playing)
+ const isPlaying = (targetSession.playback_info.PlaybackStatus === 4);
+ const currentPositionMs = isPlaying && timelineProps.EndTime > 0 ? timelineProps.Position + driftMs : timelineProps.Position;
+
+ // Update the label using your naming convention
+ currentTimeLabel.innerText =
+ ConvertMillisecondsToMinutesSoThatItLooksBetterOnTheOverlay(currentPositionMs);
+
+ durationLabel.innerText =
+ ConvertMillisecondsToMinutesSoThatItLooksBetterOnTheOverlay(timelineProps.EndTime);
+
+ // Set progressbar
+ // Ensure we don't divide by zero or exceed 100%
+ const durationMs = timelineProps.EndTime;
+ let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
+ progressPercent = Math.min(100, Math.max(0, progressPercent));
+ progressBarFill.style.width = `${progressPercent}%`;
+ progressBarFill.style.setProperty('--accent-color', mediaProps.AccentColor);
+ }
+ }
+ else
+ {
+ standardLayout.style.opacity = "0";
+ }
+}
+
+// Start polling every 1000 milliseconds
+setInterval(FetchMedia, 1000);
+
+// Run once immediately on script load
+FetchMedia();
+
+
+
+//////////////////////
+// HELPER FUNCTIONS //
+//////////////////////
+
+let hideTimeout = null; // Store the timer in a global/outer variable
+
+function SetVisibility(visible) {
+ // Always clear any pending hide timers whenever we change visibility
+ if (hideTimeout) {
+ clearTimeout(hideTimeout);
+ hideTimeout = null;
+ }
+
+ if (visible) {
+ standardLayout.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 {
+ standardLayout.style.animation = `${hideAnimation} 0.5s ease-out forwards`;
+ }
+}
+
+async function ChangeTrack(mediaProps) {
+ // Fade in the overlay (shows the new text)
+ trackLabel.style.opacity = "0";
+ artistLabel.style.opacity = "0";
+ backgroundLayer.style.opacity = "0";
+ albumArtLayer.style.opacity = "0";
+
+ // Wait for fade (0.5s), then swap the real text and hide overlay
+ setTimeout(() => {
+ trackLabel.innerText = mediaProps.Title;
+ artistLabel.innerText = mediaProps.Artist;
+
+ // Extract the image source string (use fallback if Windows has no art)
+ const newArtUrl = mediaProps.Base64Image;
+ const accent = mediaProps.AccentColor || "#ffffff";
+
+ // Set the image
+ backgroundLayer.style.backgroundImage = `url('${newArtUrl}')`;
+ albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
+
+ // Apply a tint: Use 30% opacity of the accent color + a dark overlay for contrast
+ // 'rgba(0,0,0,0.6)' ensures the text stays readable
+ backgroundLayer.style.backgroundColor = accent + "80"; // 80 is 50% opacity in hex
+
+ trackLabel.style.opacity = "";
+ artistLabel.style.opacity = "";
+ backgroundLayer.style.opacity = "";
+ albumArtLayer.style.opacity = "";
+
+ setTimeout(() => {
+ // Set the image
+ backgroundTransitionLayer.style.backgroundImage = `url('${newArtUrl}')`;
+ albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
+
+ // Apply a tint: Use 30% opacity of the accent color + a dark overlay for contrast
+ // 'rgba(0,0,0,0.6)' ensures the text stays readable
+ backgroundTransitionLayer.style.backgroundColor = accent + "80"; // 80 is 50% opacity in hex
+
+ SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
+ }, 250);
+ }, 250);
+}
+
+window.addEventListener('resize', syncHeights);
+syncHeights();
+
+function syncHeights() {
+ requestAnimationFrame(() => {
+ const height = songInfoContainer.offsetHeight;
+ console.log(height);
+ if (height > 0) {
+ albumArtContainer.style.height = `${height}px`;
+ }
+ });
+}
+
+function ConvertMillisecondsToMinutesSoThatItLooksBetterOnTheOverlay(time) {
+ if (isNaN(time) || time <= 0) return "0:00";
+
+ const totalSeconds = Math.floor(time / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ return `${minutes}:${('0' + seconds).slice(-2)}`;
+}
\ No newline at end of file
diff --git a/now-playing/settings/index.html b/now-playing/settings/index.html
new file mode 100644
index 0000000..2fd0841
--- /dev/null
+++ b/now-playing/settings/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ nutty
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/now-playing/settings/script.js b/now-playing/settings/script.js
new file mode 100644
index 0000000..33cab02
--- /dev/null
+++ b/now-playing/settings/script.js
@@ -0,0 +1,23 @@
+const widgetContainer = document.getElementById('widgetContainer');
+
+const settingsPageURL = '/.common/core/settings-core';
+
+const currentURL = window.location.href;
+
+let settingsJSON;
+let baseURL = currentURL;
+
+if (baseURL.endsWith("index.html"))
+ baseURL = baseURL.replace("index.html", "");
+
+settingsJSON = "?settingsJson=" + baseURL + "settings.json";
+
+const lastSlashIndex = baseURL.lastIndexOf("/");
+let widgetURL = "&widgetURL=" + baseURL.replace("/settings", "");
+
+console.debug("Window Ref: " + window.location.href);
+console.debug("Base URL: " + baseURL);
+console.debug("Settings JSON: " + settingsJSON);
+console.debug("Widget URL: " + widgetURL);
+
+widgetContainer.src = settingsPageURL + settingsJSON + widgetURL;
\ No newline at end of file
diff --git a/now-playing/settings/settings.json b/now-playing/settings/settings.json
new file mode 100644
index 0000000..a7ac0c0
--- /dev/null
+++ b/now-playing/settings/settings.json
@@ -0,0 +1,172 @@
+{
+ "settings": [
+ {
+ "id": "theme",
+ "label": "Theme",
+ "description": "",
+ "type": "select",
+ "options": [
+ {
+ "value": "standard",
+ "label": "Standard"
+ },
+ {
+ "value": "compact",
+ "label": "Compact"
+ },
+ {
+ "value": "simple",
+ "label": "Simple"
+ },
+ {
+ "value": "card",
+ "label": "Card"
+ }
+ ],
+ "defaultValue": "standard",
+ "group": "Appearance"
+ },
+ {
+ "id": "font",
+ "label": "Font",
+ "description": "",
+ "type": "text",
+ "defaultValue": "",
+ "group": "Appearance"
+ },
+ {
+ "id": "fontSize",
+ "label": "Font Size",
+ "description": "",
+ "type": "number",
+ "defaultValue": 20,
+ "group": "Appearance"
+ },
+ {
+ "id": "width",
+ "label": "Width",
+ "description": "",
+ "type": "number",
+ "defaultValue": 500,
+ "group": "Appearance"
+ },
+ {
+ "id": "albumArt",
+ "label": "Album Art",
+ "description": "",
+ "type": "select",
+ "options": [
+ {
+ "value": "none",
+ "label": "Don't display"
+ },
+ {
+ "value": "show",
+ "label": "Show"
+ },
+ {
+ "value": "spinny",
+ "label": "Spinny disc thingy cause that looks dope as hell"
+ }
+ ],
+ "defaultValue": "none",
+ "group": "Appearance"
+ },
+ {
+ "id": "showProgressBar",
+ "label": "Show Progress Bar",
+ "description": "Some apps do not display progress info correctly.",
+ "type": "checkbox",
+ "defaultValue": true,
+ "group": "General"
+ },
+ {
+ "id": "targetApplication",
+ "label": "Target Application",
+ "description": "Specific app to track. Leave empty to automatically follow the active player.
View active sources",
+ "type": "text",
+ "placeholder": "e.g., Spotify.exe",
+ "defaultValue": "",
+ "group": "General"
+ },
+ {
+ "id": "autoHide",
+ "label": "Auto-Hide on Track Change",
+ "description": "If enabled, the widget will show for a set duration whenever a new track starts.",
+ "type": "checkbox",
+ "defaultValue": false,
+ "group": "General"
+ },
+ {
+ "id": "displayDuration",
+ "label": "Display Duration (seconds)",
+ "description": "How long the widget should remain visible.",
+ "type": "number",
+ "min": 1,
+ "max": 60,
+ "defaultValue": 5,
+ "showIf": "autoHide",
+ "group": "General"
+ },
+ {
+ "id": "showAnimation",
+ "label": "Show Animation",
+ "description": "",
+ "type": "select",
+ "options": [
+ {
+ "value": "fade-in",
+ "label": "Fade In"
+ },
+ {
+ "value": "slide-in-from-top",
+ "label": "Slide In From Top"
+ },
+ {
+ "value": "slide-in-from-bottom",
+ "label": "Slide In From Bottom"
+ },
+ {
+ "value": "slide-in-from-left",
+ "label": "Slide In From Left"
+ },
+ {
+ "value": "slide-in-from-right",
+ "label": "Slide In From Right"
+ }
+ ],
+ "defaultValue": "slide-in-from-bottom",
+ "group": "General"
+ },
+ {
+ "id": "hideAnimation",
+ "label": "Hide Animation",
+ "description": "",
+ "type": "select",
+ "options": [
+ {
+ "value": "fade-out",
+ "label": "Fade Out"
+ },
+ {
+ "value": "slide-out-top",
+ "label": "Slide Out Top"
+ },
+ {
+ "value": "slide-out-bottom",
+ "label": "Slide Out Down"
+ },
+ {
+ "value": "slide-out-left",
+ "label": "Slide Out Left"
+ },
+ {
+ "value": "slide-out-right",
+ "label": "Slide Out Right"
+ }
+ ],
+ "defaultValue": "slide-out-bottom",
+ "group": "General"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/now-playing/smtc-bridge.py b/now-playing/smtc-bridge.py
new file mode 100644
index 0000000..d43593a
--- /dev/null
+++ b/now-playing/smtc-bridge.py
@@ -0,0 +1,173 @@
+import sys
+import subprocess
+
+# --- SELF-HEALING DEPENDENCY CHECK ---
+def check_dependencies():
+ required = {'flask': 'flask', 'flask_cors': 'flask_cors', 'winsdk': 'winsdk', 'PIL': 'Pillow'}
+ for mod, pkg in required.items():
+ try:
+ __import__(mod)
+ except ImportError:
+ print(f"--- Missing dependency: {pkg}. Installing now... ---")
+ try:
+ subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
+ except subprocess.CalledProcessError:
+ print(f"--- Failed to install {pkg}. Please install it manually. ---")
+ sys.exit(0)
+
+check_dependencies()
+
+import asyncio
+import json
+import base64
+import io
+from PIL import Image
+from flask import Flask, jsonify
+from flask_cors import CORS
+from winsdk.windows.media.control import GlobalSystemMediaTransportControlsSessionManager as SMTC
+from winsdk.windows.storage.streams import DataReader
+
+app = Flask(__name__)
+CORS(app)
+
+# --- GLOBAL MEMORY CACHE ---
+ARTWORK_CACHE = {}
+
+def get_smart_accent(img):
+ """Sorts pixels by saturation to find the most vibrant color."""
+ thumb = img.copy()
+ thumb.thumbnail((20, 20)) # Higher sample rate than 1x1
+ pixels = list(thumb.getdata())
+
+ # Sort by saturation: (max_channel - min_channel)
+ pixels.sort(key=lambda p: max(p) - min(p), reverse=True)
+ best_rgb = pixels[0]
+
+ # Safety boost: ensure it's readable and vibrant
+ min_val = 80
+ rgb = [max(c, min_val) for c in best_rgb]
+ rgb = [min(int(c * 1.2), 255) for c in rgb]
+
+ return '#{:02x}{:02x}{:02x}'.format(*rgb)
+
+async def get_all_media_info():
+ global ARTWORK_CACHE
+ import winsdk._winrt
+ try:
+ winsdk._winrt.init_apartment(1)
+ except Exception:
+ pass
+
+ try:
+ manager = await SMTC.request_async()
+ if not manager:
+ return {"current_session_id": None, "sessions": []}
+
+ current_focused = manager.get_current_session()
+ current_session_id = current_focused.source_app_user_model_id if current_focused else None
+
+ all_sessions = manager.get_sessions()
+ sessions_list = []
+
+ for session in all_sessions:
+ app_id = session.source_app_user_model_id
+ raw_playback = session.get_playback_info()
+ raw_timeline = session.get_timeline_properties()
+ raw_media = await session.try_get_media_properties_async()
+
+ playback_data = {
+ "PlaybackStatus": raw_playback.playback_status.value if (raw_playback and raw_playback.playback_status) else 0,
+ "PlaybackType": raw_playback.playback_type.value if (raw_playback and raw_playback.playback_type) else 0,
+ }
+
+ timeline_data = {
+ "Position": int(raw_timeline.position.total_seconds() * 1000) if raw_timeline.position else 0,
+ "EndTime": int(raw_timeline.end_time.total_seconds() * 1000) if raw_timeline.end_time else 0,
+ "LastUpdatedTime": str(raw_timeline.last_updated_time) if raw_timeline.last_updated_time else None
+ }
+
+ title = raw_media.title if raw_media else "Unknown"
+ artist = raw_media.artist if raw_media else "Unknown"
+ track_key = f"{title} - {artist}"
+
+ media_data = {"Title": title, "Artist": artist, "Base64Image": None, "AccentColor": "#ffffff"}
+
+ if app_id in ARTWORK_CACHE and ARTWORK_CACHE[app_id]["track_key"] == track_key:
+ media_data["Base64Image"] = ARTWORK_CACHE[app_id]["base64"]
+ media_data["AccentColor"] = ARTWORK_CACHE[app_id]["accent"]
+
+ elif raw_media and raw_media.thumbnail:
+ try:
+ stream_ref = raw_media.thumbnail
+ stream = await stream_ref.open_read_async()
+ reader = DataReader(stream.get_input_stream_at(0))
+ await reader.load_async(stream.size)
+ buffer = bytearray(stream.size)
+ reader.read_bytes(buffer)
+
+ img = Image.open(io.BytesIO(buffer))
+ hex_color = get_smart_accent(img)
+ base64_art = f"data:image/png;base64,{base64.b64encode(buffer).decode('utf-8')}"
+
+ ARTWORK_CACHE[app_id] = {"track_key": track_key, "base64": base64_art, "accent": hex_color}
+ media_data["Base64Image"] = base64_art
+ media_data["AccentColor"] = hex_color
+ except Exception:
+ pass
+
+ sessions_list.append({
+ "source_app_id": app_id,
+ "playback_info": playback_data,
+ "timeline_properties": timeline_data,
+ "media_properties": media_data
+ })
+
+ return {"current_session_id": current_session_id, "sessions": sessions_list}
+ except Exception as e:
+ return {"current_session_id": None, "sessions": [], "error": str(e)}
+
+@app.route('/now-playing')
+def now_playing():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return jsonify(loop.run_until_complete(get_all_media_info()))
+ finally:
+ loop.close()
+
+@app.route('/sessions', methods=['GET'])
+def get_sessions():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ async def fetch():
+ manager = await SMTC.request_async()
+ if not manager: return []
+ return list(set([s.source_app_user_model_id for s in manager.get_sessions()]))
+
+ try:
+ sessions = loop.run_until_complete(fetch())
+
+ # Wrapped in a tag with a dark background and some padding
+ html_list = """
+
+ Active Audio Sources:
+
+ """
+
+ if not sessions:
+ html_list += "- No active audio sources found.
"
+ else:
+ for s in sessions:
+ html_list += f"- {s}
"
+
+ html_list += "
"
+ return html_list
+
+ except Exception as e:
+ return f"Error: {str(e)}"
+ finally:
+ loop.close()
+
+if __name__ == '__main__':
+ app.run(port=5000, threaded=True)
\ No newline at end of file
diff --git a/now-playing/style.css b/now-playing/style.css
new file mode 100644
index 0000000..0ae485f
--- /dev/null
+++ b/now-playing/style.css
@@ -0,0 +1,303 @@
+body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
+ font-size: 20px;
+ color: white;
+ margin: 0;
+}
+
+#main-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh; /* Full viewport height */
+}
+
+#standard-layout {
+ width: 500px;
+ display: flex;
+ flex-direction: row;
+ /* This forces both containers to be the same height */
+ align-items: stretch;
+ gap: 0.5em;
+}
+
+#album-art-container {
+ /* 1. Tell it to maintain a 1:1 square ratio */
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ border-radius: 1em;
+ position: relative;
+ background-size: cover;
+ background-position: center;
+}
+
+#art-layer,
+#art-transition-layer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center;
+ transition: opacity 0.5s ease;
+}
+
+#art-layer {
+ z-index: 1;
+ transition: all 0.5s ease;
+}
+
+#art-transition-layer {
+ z-index: 0;
+}
+
+#song-info-container {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ border-radius: 1em;
+ overflow: hidden;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
+}
+
+/* The background now fills the frame */
+#background-layer,
+#background-transition-layer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ background-size: cover;
+ background-position: center center;
+ background-repeat: no-repeat;
+
+ background-blend-mode: overlay; /* Or 'soft-light' for a subtler effect */
+ filter: blur(0px) brightness(0.3);
+ transform: scale(1.1); /* Slightly zoom in to cover edges when blurred */
+}
+
+#background-layer {
+ transition: all 0.5s ease;
+}
+
+#background-transition-layer {
+ z-index: 0;
+}
+
+/* The content sits on top */
+#song-info-wrapper {
+ position: relative; /* Keeps it in the document flow */
+ z-index: 2; /* Sits above the background-layer */
+
+ padding: 1em 1.75em;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3em;
+}
+
+#track-label {
+ font-size: 1em;
+ font-weight: 600;
+}
+
+#artist-label {
+ font-size: 0.8em;
+ font-weight: 300;
+ opacity: 0.6;
+}
+
+.text-label {
+ white-space: nowrap;
+ overflow: hidden;
+ mask-image: linear-gradient(to right, black calc(100% - 1em), transparent 100%);
+ -webkit-mask-image: linear-gradient(to right, black calc(100% - 1em), transparent 100%);
+ transition: opacity 0.25s ease-in-out;
+}
+
+#progress-container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2em;
+ margin-top: 0.2em;
+}
+
+#progress-bar-track {
+ width: 100%;
+ height: 0.4em;
+ border-radius: 1em;
+ background-color: #ffffff3a;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
+}
+
+#progress-bar-fill {
+ height: 100%;
+ width: 0%; /* JS will control this */
+ border-radius: 1em;
+ background-color: var(--accent-color, #ffffff);
+ transition: width 1s ease-in-out, background-color 1s ease-in-out;
+
+ /* This makes the handle track the end of the bar */
+ position: relative;
+}
+
+#progress-bar-fill::after {
+ content: '';
+ position: absolute;
+ right: -6px; /* Adjust to center the circle on the end of the bar */
+ top: 50%;
+ transform: translateY(-50%);
+
+ width: 0.8em; /* Diameter of your circle */
+ height: 0.8em;
+ background-color: var(--accent-color, #ffffff);
+ border-radius: 50%;
+ filter: brightness(1.4);
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
+
+ /* Optional: Hide it if the width is too small */
+ opacity: 0;
+ transition: all 1s ease-in-out;
+}
+
+/* Show the handle when the progress bar is active */
+#progress-bar-track:hover #progress-bar-fill::after,
+#progress-bar-fill:not([style*="width: 0%"])::after {
+ opacity: 1;
+}
+
+#time-container {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.7em;
+ opacity: 0.5;
+}
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes fade-out {
+ 0% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}@keyframes slide-in-from-top {
+ 0% {
+ transform: translateY(-1em);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-in-from-top {
+ 0% {
+ transform: translateY(-1em);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-out-top {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateY(-1em);
+ opacity: 0;
+ }
+}
+
+@keyframes slide-in-from-bottom {
+ 0% {
+ transform: translateY(1em);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-out-bottom {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateY(1em);
+ opacity: 0;
+ }
+}
+
+@keyframes slide-in-from-left {
+ 0% {
+ transform: translateX(-1em);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-out-left {
+ 0% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(-1em);
+ opacity: 0;
+ }
+}
+
+@keyframes slide-in-from-right {
+ 0% {
+ transform: translateX(1em);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-out-right {
+ 0% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(1em);
+ opacity: 0;
+ }
+}
\ No newline at end of file