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):
|
||||
"""
|
||||
Performs network detection for a given server type (plex, jellyfin, slskd).
|
||||
This is a blocking function that scans the network.
|
||||
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
|
||||
This implements the same scanning logic as the GUI's detection threads.
|
||||
"""
|
||||
# This is a simplified version of the logic in your QThreads.
|
||||
# A full implementation would be more extensive.
|
||||
# For demonstration, we'll check common local addresses.
|
||||
print(f"Running detection for {server_type}...")
|
||||
common_ips = ["localhost", "127.0.0.1"]
|
||||
ports = {
|
||||
'plex': 32400,
|
||||
'jellyfin': 8096,
|
||||
'slskd': 5030
|
||||
}
|
||||
port = ports.get(server_type)
|
||||
if not port:
|
||||
print(f"Running comprehensive detection for {server_type}...")
|
||||
|
||||
def get_network_info():
|
||||
"""Get comprehensive network information with subnet detection"""
|
||||
try:
|
||||
# Get local IP using socket method
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# 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
|
||||
|
||||
for ip in common_ips:
|
||||
url = f"http://{ip}:{port}"
|
||||
def test_jellyfin_server(ip, port=8096):
|
||||
"""Test if a Jellyfin server is running at the given IP and port"""
|
||||
try:
|
||||
if server_type == 'slskd':
|
||||
# slskd check is different
|
||||
response = requests.get(f"{url}/api/v0/session", timeout=1)
|
||||
if response.status_code in [200, 401]:
|
||||
print(f"Found {server_type} at {url}")
|
||||
return url
|
||||
else:
|
||||
response = requests.get(url, timeout=1)
|
||||
# A simple 200 OK is a good sign
|
||||
if response.status_code == 200:
|
||||
print(f"Found {server_type} at {url}")
|
||||
return url
|
||||
except requests.RequestException:
|
||||
continue
|
||||
return None
|
||||
# Try the system info endpoint first
|
||||
url = f"http://{ip}:{port}/System/Info"
|
||||
response = requests.get(url, timeout=2, allow_redirects=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Check if response contains Jellyfin-specific content
|
||||
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
# Also try the web interface
|
||||
web_url = f"http://{ip}:{port}/web/index.html"
|
||||
web_response = requests.get(web_url, timeout=1)
|
||||
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
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 ---
|
||||
|
||||
|
|
@ -395,6 +546,59 @@ def stream_stop():
|
|||
# Placeholder: simulates stopping a stream
|
||||
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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -463,6 +463,29 @@
|
|||
<!-- Toast Notifications -->
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -993,8 +993,99 @@ function escapeHtml(text) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showVersionInfo() {
|
||||
showToast('Version info modal not implemented yet', 'error');
|
||||
async function showVersionInfo() {
|
||||
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.copyAddress = copyAddress;
|
||||
window.showVersionInfo = showVersionInfo;
|
||||
window.closeVersionModal = closeVersionModal;
|
||||
window.testConnection = testConnection;
|
||||
window.autoDetectPlex = autoDetectPlex;
|
||||
window.autoDetectJellyfin = autoDetectJellyfin;
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ body {
|
|||
.crypto-donation {
|
||||
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-bottom-right-radius: 12px;
|
||||
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
|
|
@ -474,8 +474,8 @@ body {
|
|||
height: 45px;
|
||||
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-radius: 8px;
|
||||
margin: 0 10px;
|
||||
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -494,6 +494,7 @@ body {
|
|||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.version-button:hover {
|
||||
|
|
@ -508,7 +509,7 @@ body {
|
|||
|
||||
/* Status Section - Matching status indicators */
|
||||
.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%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom-right-radius: 12px;
|
||||
|
|
@ -1428,4 +1429,203 @@ body {
|
|||
.settings-content {
|
||||
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