settings auto detect fixed
This commit is contained in:
parent
d631ad61f8
commit
36b3830a2f
4 changed files with 556 additions and 37 deletions
266
web_server.py
266
web_server.py
|
|
@ -119,41 +119,192 @@ def run_service_test(service, test_config):
|
||||||
|
|
||||||
def run_detection(server_type):
|
def run_detection(server_type):
|
||||||
"""
|
"""
|
||||||
Performs network detection for a given server type (plex, jellyfin, slskd).
|
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
|
||||||
This is a blocking function that scans the network.
|
This implements the same scanning logic as the GUI's detection threads.
|
||||||
"""
|
"""
|
||||||
# This is a simplified version of the logic in your QThreads.
|
print(f"Running comprehensive detection for {server_type}...")
|
||||||
# A full implementation would be more extensive.
|
|
||||||
# For demonstration, we'll check common local addresses.
|
def get_network_info():
|
||||||
print(f"Running detection for {server_type}...")
|
"""Get comprehensive network information with subnet detection"""
|
||||||
common_ips = ["localhost", "127.0.0.1"]
|
try:
|
||||||
ports = {
|
# Get local IP using socket method
|
||||||
'plex': 32400,
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
'jellyfin': 8096,
|
s.connect(("8.8.8.8", 80))
|
||||||
'slskd': 5030
|
local_ip = s.getsockname()[0]
|
||||||
}
|
s.close()
|
||||||
port = ports.get(server_type)
|
|
||||||
if not port:
|
# Try to get actual subnet mask
|
||||||
|
try:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
# Windows: Use netsh to get subnet info
|
||||||
|
result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'],
|
||||||
|
capture_output=True, text=True, timeout=3)
|
||||||
|
# Parse output for subnet mask (simplified)
|
||||||
|
subnet_mask = "255.255.255.0" # Default fallback
|
||||||
|
else:
|
||||||
|
# Linux/Mac: Try to parse network interfaces
|
||||||
|
result = subprocess.run(['ip', 'route', 'show'],
|
||||||
|
capture_output=True, text=True, timeout=3)
|
||||||
|
subnet_mask = "255.255.255.0" # Default fallback
|
||||||
|
except:
|
||||||
|
subnet_mask = "255.255.255.0" # Default /24
|
||||||
|
|
||||||
|
# Calculate network range
|
||||||
|
network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False)
|
||||||
|
return str(network.network_address), str(network.netmask), local_ip, network
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to original method
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
local_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
# Default to /24 network
|
||||||
|
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
|
||||||
|
return str(network.network_address), "255.255.255.0", local_ip, network
|
||||||
|
|
||||||
|
def test_plex_server(ip, port=32400):
|
||||||
|
"""Test if a Plex server is running at the given IP and port"""
|
||||||
|
try:
|
||||||
|
url = f"http://{ip}:{port}/web/index.html"
|
||||||
|
response = requests.get(url, timeout=2, allow_redirects=True)
|
||||||
|
|
||||||
|
# Check for Plex-specific indicators
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Check if it's actually Plex
|
||||||
|
if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers):
|
||||||
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
|
# Also try the API endpoint
|
||||||
|
api_url = f"http://{ip}:{port}/identity"
|
||||||
|
api_response = requests.get(api_url, timeout=1)
|
||||||
|
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
||||||
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for ip in common_ips:
|
def test_jellyfin_server(ip, port=8096):
|
||||||
url = f"http://{ip}:{port}"
|
"""Test if a Jellyfin server is running at the given IP and port"""
|
||||||
try:
|
try:
|
||||||
if server_type == 'slskd':
|
# Try the system info endpoint first
|
||||||
# slskd check is different
|
url = f"http://{ip}:{port}/System/Info"
|
||||||
response = requests.get(f"{url}/api/v0/session", timeout=1)
|
response = requests.get(url, timeout=2, allow_redirects=True)
|
||||||
if response.status_code in [200, 401]:
|
|
||||||
print(f"Found {server_type} at {url}")
|
if response.status_code == 200:
|
||||||
return url
|
# Check if response contains Jellyfin-specific content
|
||||||
else:
|
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
|
||||||
response = requests.get(url, timeout=1)
|
return f"http://{ip}:{port}"
|
||||||
# A simple 200 OK is a good sign
|
|
||||||
if response.status_code == 200:
|
# Also try the web interface
|
||||||
print(f"Found {server_type} at {url}")
|
web_url = f"http://{ip}:{port}/web/index.html"
|
||||||
return url
|
web_response = requests.get(web_url, timeout=1)
|
||||||
except requests.RequestException:
|
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
||||||
continue
|
return f"http://{ip}:{port}"
|
||||||
return None
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_slskd_server(ip, port=5030):
|
||||||
|
"""Test if a slskd server is running at the given IP and port"""
|
||||||
|
try:
|
||||||
|
# slskd specific API endpoint
|
||||||
|
url = f"http://{ip}:{port}/api/v0/session"
|
||||||
|
response = requests.get(url, timeout=2)
|
||||||
|
|
||||||
|
# slskd returns 401 when not authenticated, which is still a valid response
|
||||||
|
if response.status_code in [200, 401]:
|
||||||
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
network_addr, netmask, local_ip, network = get_network_info()
|
||||||
|
|
||||||
|
# Select the appropriate test function
|
||||||
|
test_functions = {
|
||||||
|
'plex': test_plex_server,
|
||||||
|
'jellyfin': test_jellyfin_server,
|
||||||
|
'slskd': test_slskd_server
|
||||||
|
}
|
||||||
|
|
||||||
|
test_func = test_functions.get(server_type)
|
||||||
|
if not test_func:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Priority 1: Test localhost first
|
||||||
|
print(f"Testing localhost for {server_type}...")
|
||||||
|
localhost_result = test_func("localhost")
|
||||||
|
if localhost_result:
|
||||||
|
print(f"Found {server_type} at localhost!")
|
||||||
|
return localhost_result
|
||||||
|
|
||||||
|
# Priority 2: Test local IP
|
||||||
|
print(f"Testing local IP {local_ip} for {server_type}...")
|
||||||
|
local_result = test_func(local_ip)
|
||||||
|
if local_result:
|
||||||
|
print(f"Found {server_type} at {local_ip}!")
|
||||||
|
return local_result
|
||||||
|
|
||||||
|
# Priority 3: Test common IPs (router gateway, etc.)
|
||||||
|
common_ips = [
|
||||||
|
local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway
|
||||||
|
local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway
|
||||||
|
local_ip.rsplit('.', 1)[0] + '.100', # Common static IP
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Testing common IPs for {server_type}...")
|
||||||
|
for ip in common_ips:
|
||||||
|
print(f" Checking {ip}...")
|
||||||
|
result = test_func(ip)
|
||||||
|
if result:
|
||||||
|
print(f"Found {server_type} at {ip}!")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Priority 4: Scan the network range (limited to reasonable size)
|
||||||
|
network_hosts = list(network.hosts())
|
||||||
|
if len(network_hosts) > 50:
|
||||||
|
# Limit scan to reasonable size for performance
|
||||||
|
step = max(1, len(network_hosts) // 50)
|
||||||
|
network_hosts = network_hosts[::step]
|
||||||
|
|
||||||
|
print(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...")
|
||||||
|
|
||||||
|
# Use ThreadPoolExecutor for concurrent scanning (limited for web context)
|
||||||
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
# Submit all tasks
|
||||||
|
future_to_ip = {executor.submit(test_func, str(ip)): str(ip)
|
||||||
|
for ip in network_hosts}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for future in as_completed(future_to_ip):
|
||||||
|
ip = future_to_ip[future]
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
if result:
|
||||||
|
print(f"Found {server_type} at {ip}!")
|
||||||
|
# Cancel all pending futures before returning
|
||||||
|
for f in future_to_ip:
|
||||||
|
if not f.done():
|
||||||
|
f.cancel()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error testing {ip}: {e}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in concurrent scanning: {e}")
|
||||||
|
|
||||||
|
print(f"No {server_type} server found on network")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during {server_type} detection: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# --- Web UI Routes ---
|
# --- Web UI Routes ---
|
||||||
|
|
||||||
|
|
@ -395,6 +546,59 @@ def stream_stop():
|
||||||
# Placeholder: simulates stopping a stream
|
# Placeholder: simulates stopping a stream
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
@app.route('/api/version-info', methods=['GET'])
|
||||||
|
def get_version_info():
|
||||||
|
"""
|
||||||
|
Returns version information and release notes, matching the GUI's VersionInfoModal content.
|
||||||
|
This provides the same data that the GUI version modal displays.
|
||||||
|
"""
|
||||||
|
version_data = {
|
||||||
|
"version": "0.65",
|
||||||
|
"title": "What's New in SoulSync",
|
||||||
|
"subtitle": "Version 0.65 - Tidal Playlist Integration",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "🎵 Complete Tidal Playlist Integration",
|
||||||
|
"description": "Full Tidal playlist support with seamless workflow integration matching YouTube/Spotify functionality",
|
||||||
|
"features": [
|
||||||
|
"• Native Tidal API client with OAuth 2.0 authentication and automatic token management",
|
||||||
|
"• Tidal playlist tab positioned between Spotify and YouTube with identical UI/UX patterns",
|
||||||
|
"• Advanced playlist card system with persistent state tracking across all phases",
|
||||||
|
"• Complete discovery workflow: discovering → discovered → syncing → downloading phases",
|
||||||
|
"• Intelligent track matching using existing Spotify-based algorithms for compatibility",
|
||||||
|
"• Smart modal routing with proper state persistence (close/cancel behavior)",
|
||||||
|
"• Full refresh functionality with comprehensive worker cleanup and modal management"
|
||||||
|
],
|
||||||
|
"usage_note": "Configure Tidal in Settings → Connections, then discover and sync your Tidal playlists just like Spotify!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "⚙️ Advanced Workflow Features",
|
||||||
|
"description": "Sophisticated state management and user experience improvements",
|
||||||
|
"features": [
|
||||||
|
"• Identical workflow behavior across all playlist sources (Spotify, YouTube, Tidal)",
|
||||||
|
"• Smart refresh system that cancels all active operations and preserves playlist names",
|
||||||
|
"• Phase-aware card clicking: routes to discovery, sync progress, or download modals appropriately",
|
||||||
|
"• Proper modal state persistence: closing download modals preserves discovery state",
|
||||||
|
"• Cancel operations reset playlists to fresh state for updated playlist data",
|
||||||
|
"• Multi-server compatibility: works with both Plex and Jellyfin automatically"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "🔧 Technical Implementation Details",
|
||||||
|
"description": "Robust architecture ensuring reliable playlist management across all sources",
|
||||||
|
"features": [
|
||||||
|
"• Implemented comprehensive state tracking system with playlist card hub architecture",
|
||||||
|
"• Added PKCE (Proof Key for Code Exchange) OAuth flow for enhanced Tidal security",
|
||||||
|
"• Created unified modal system supporting YouTube, Spotify, and Tidal workflows",
|
||||||
|
"• Enhanced worker cancellation system for proper resource cleanup during operations",
|
||||||
|
"• JSON:API response parsing for Tidal's complex relationship-based data structure",
|
||||||
|
"• Future-ready architecture for additional music streaming service integrations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return jsonify(version_data)
|
||||||
|
|
||||||
|
|
||||||
# --- Main Execution ---
|
# --- Main Execution ---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,29 @@
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Version Info Modal -->
|
||||||
|
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
|
||||||
|
<div class="version-modal" onclick="event.stopPropagation()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="version-modal-header">
|
||||||
|
<h2 class="version-modal-title">What's New in SoulSync</h2>
|
||||||
|
<div class="version-modal-subtitle">Version 0.65 - Tidal Playlist Integration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Area with Scroll -->
|
||||||
|
<div class="version-modal-content">
|
||||||
|
<div class="version-content-container" id="version-content-container">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="version-modal-footer">
|
||||||
|
<button class="version-modal-close" onclick="closeVersionModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -993,8 +993,99 @@ function escapeHtml(text) {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showVersionInfo() {
|
async function showVersionInfo() {
|
||||||
showToast('Version info modal not implemented yet', 'error');
|
try {
|
||||||
|
console.log('Fetching version info...');
|
||||||
|
|
||||||
|
// Fetch version data from API
|
||||||
|
const response = await fetch('/api/version-info');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch version info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionData = await response.json();
|
||||||
|
console.log('Version data received:', versionData);
|
||||||
|
|
||||||
|
// Populate modal content
|
||||||
|
populateVersionModal(versionData);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modalOverlay = document.getElementById('version-modal-overlay');
|
||||||
|
modalOverlay.classList.remove('hidden');
|
||||||
|
|
||||||
|
console.log('Version modal opened');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error showing version info:', error);
|
||||||
|
showToast('Failed to load version information', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVersionModal() {
|
||||||
|
const modalOverlay = document.getElementById('version-modal-overlay');
|
||||||
|
modalOverlay.classList.add('hidden');
|
||||||
|
console.log('Version modal closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVersionModal(versionData) {
|
||||||
|
const container = document.getElementById('version-content-container');
|
||||||
|
if (!container) {
|
||||||
|
console.error('Version content container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update header with dynamic data
|
||||||
|
const titleElement = document.querySelector('.version-modal-title');
|
||||||
|
const subtitleElement = document.querySelector('.version-modal-subtitle');
|
||||||
|
|
||||||
|
if (titleElement) titleElement.textContent = versionData.title;
|
||||||
|
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
|
||||||
|
|
||||||
|
// Clear existing content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Create sections
|
||||||
|
versionData.sections.forEach(section => {
|
||||||
|
const sectionDiv = document.createElement('div');
|
||||||
|
sectionDiv.className = 'version-feature-section';
|
||||||
|
|
||||||
|
// Section title
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
titleDiv.className = 'version-section-title';
|
||||||
|
titleDiv.textContent = section.title;
|
||||||
|
sectionDiv.appendChild(titleDiv);
|
||||||
|
|
||||||
|
// Section description
|
||||||
|
const descDiv = document.createElement('div');
|
||||||
|
descDiv.className = 'version-section-description';
|
||||||
|
descDiv.textContent = section.description;
|
||||||
|
sectionDiv.appendChild(descDiv);
|
||||||
|
|
||||||
|
// Features list
|
||||||
|
const featuresList = document.createElement('ul');
|
||||||
|
featuresList.className = 'version-feature-list';
|
||||||
|
|
||||||
|
section.features.forEach(feature => {
|
||||||
|
const featureItem = document.createElement('li');
|
||||||
|
featureItem.className = 'version-feature-item';
|
||||||
|
featureItem.textContent = feature;
|
||||||
|
featuresList.appendChild(featureItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
sectionDiv.appendChild(featuresList);
|
||||||
|
|
||||||
|
// Usage note (if present)
|
||||||
|
if (section.usage_note) {
|
||||||
|
const usageDiv = document.createElement('div');
|
||||||
|
usageDiv.className = 'version-usage-note';
|
||||||
|
usageDiv.textContent = `💡 ${section.usage_note}`;
|
||||||
|
sectionDiv.appendChild(usageDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(sectionDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Version modal content populated');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -1174,6 +1265,7 @@ window.navigateToPage = navigateToPage;
|
||||||
window.openKofi = openKofi;
|
window.openKofi = openKofi;
|
||||||
window.copyAddress = copyAddress;
|
window.copyAddress = copyAddress;
|
||||||
window.showVersionInfo = showVersionInfo;
|
window.showVersionInfo = showVersionInfo;
|
||||||
|
window.closeVersionModal = closeVersionModal;
|
||||||
window.testConnection = testConnection;
|
window.testConnection = testConnection;
|
||||||
window.autoDetectPlex = autoDetectPlex;
|
window.autoDetectPlex = autoDetectPlex;
|
||||||
window.autoDetectJellyfin = autoDetectJellyfin;
|
window.autoDetectJellyfin = autoDetectJellyfin;
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,7 @@ body {
|
||||||
.crypto-donation {
|
.crypto-donation {
|
||||||
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-bottom-right-radius: 12px;
|
|
||||||
padding: 15px 0;
|
padding: 15px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,8 +474,8 @@ body {
|
||||||
height: 45px;
|
height: 45px;
|
||||||
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 8px;
|
|
||||||
margin: 0 10px;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -494,6 +494,7 @@ body {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-button:hover {
|
.version-button:hover {
|
||||||
|
|
@ -508,7 +509,7 @@ body {
|
||||||
|
|
||||||
/* Status Section - Matching status indicators */
|
/* Status Section - Matching status indicators */
|
||||||
.status-section {
|
.status-section {
|
||||||
height: 150px;
|
height: fit-content;
|
||||||
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-bottom-right-radius: 12px;
|
border-bottom-right-radius: 12px;
|
||||||
|
|
@ -1428,4 +1429,203 @@ body {
|
||||||
.settings-content {
|
.settings-content {
|
||||||
max-width: 900px; /* Wider settings forms */
|
max-width: 900px; /* Wider settings forms */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== VERSION MODAL STYLES ===== */
|
||||||
|
|
||||||
|
.version-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
height: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: scale(1);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-overlay.hidden .version-modal {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.version-modal-header {
|
||||||
|
padding: 30px 30px 15px 30px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-top-right-radius: 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.version-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-content::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #555555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-content-container {
|
||||||
|
padding: 25px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feature Sections */
|
||||||
|
.version-feature-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-left: 3px solid rgba(29, 185, 84, 0.4);
|
||||||
|
margin-left: 5px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-section-title {
|
||||||
|
color: #1ed760;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-section-description {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-feature-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-feature-item {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 2px 0 2px 8px;
|
||||||
|
margin: 2px 0;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-usage-note {
|
||||||
|
color: #1ed760;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.version-modal-footer {
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-bottom-left-radius: 12px;
|
||||||
|
border-bottom-right-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-close {
|
||||||
|
background: #1db954;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 25px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-close:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-close:active {
|
||||||
|
background: #169c46;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for modal appearance */
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal-overlay:not(.hidden) .version-modal {
|
||||||
|
animation: modalFadeIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue