From ac97a901b40cdf364dad716d9502589e93aa2998 Mon Sep 17 00:00:00 2001 From: nutty Date: Sat, 27 Jun 2026 01:36:53 +1000 Subject: [PATCH] Added new "Now Playing" widget --- .common/enums/smtc-enums.js | 28 +++ now-playing/index.html | 46 +++++ now-playing/launch-bridge.bat | 4 + now-playing/script.js | 271 ++++++++++++++++++++++++++ now-playing/settings/index.html | 29 +++ now-playing/settings/script.js | 23 +++ now-playing/settings/settings.json | 172 ++++++++++++++++ now-playing/smtc-bridge.py | 173 ++++++++++++++++ now-playing/style.css | 303 +++++++++++++++++++++++++++++ 9 files changed, 1049 insertions(+) create mode 100644 .common/enums/smtc-enums.js create mode 100644 now-playing/index.html create mode 100644 now-playing/launch-bridge.bat create mode 100644 now-playing/script.js create mode 100644 now-playing/settings/index.html create mode 100644 now-playing/settings/script.js create mode 100644 now-playing/settings/settings.json create mode 100644 now-playing/smtc-bridge.py create mode 100644 now-playing/style.css 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 + + + + + + +
+
+
+
+
+
+ +
+
+
+ +
+
 
+
 
+ +
+
+
+
+
+ +
+
0:00
+
0:00
+
+
+
+
+
+
+ + + + + + \ 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:

+ " + 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