Merge branch 'main' of https://github.com/monochrome-music/monochrome
|
|
@ -20,6 +20,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".AudioPlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
@ -32,4 +37,7 @@
|
|||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
package tf.monochrome.music;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the app process alive while audio is playing
|
||||
* in the background. Without this, Android will throttle the WebView and
|
||||
* suspend Web Audio API processing, causing audible skips and dropouts.
|
||||
*/
|
||||
public class AudioPlaybackService extends Service {
|
||||
|
||||
private static final String CHANNEL_ID = "audio_playback";
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
createNotificationChannel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && "STOP".equals(intent.getAction())) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
Notification notification = buildNotification();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
|
||||
acquireWakeLock();
|
||||
|
||||
// If the system kills this service, don't restart it automatically —
|
||||
// MainActivity will re-start it when audio resumes.
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseWakeLock();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Audio Playback",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("Keeps audio playing in the background");
|
||||
channel.setSound(null, null);
|
||||
channel.setShowBadge(false);
|
||||
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private Notification buildNotification() {
|
||||
Intent launchIntent = new Intent(this, MainActivity.class);
|
||||
launchIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Monochrome")
|
||||
.setContentText("Playing audio")
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
if (wakeLock != null && !wakeLock.isHeld()) {
|
||||
wakeLock = null;
|
||||
}
|
||||
if (wakeLock == null) {
|
||||
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||
if (pm != null) {
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"monochrome:audio_playback"
|
||||
);
|
||||
// 4-hour timeout as a safety net to prevent battery drain
|
||||
// if the service is accidentally left running
|
||||
wakeLock.acquire(4 * 60 * 60 * 1000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package tf.monochrome.music;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that exposes start/stop controls for the foreground
|
||||
* AudioPlaybackService. Called from JS when audio playback begins or ends
|
||||
* so Android keeps the process alive in the background.
|
||||
*/
|
||||
@CapacitorPlugin(name = "BackgroundAudio")
|
||||
public class BackgroundAudioPlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
|
||||
intent.setAction("START");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getContext().startForegroundService(intent);
|
||||
} else {
|
||||
getContext().startService(intent);
|
||||
}
|
||||
call.resolve();
|
||||
} catch (Exception e) {
|
||||
call.reject("Failed to start audio service: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
`@PluginMethod`
|
||||
public void stop(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
|
||||
intent.setAction("STOP");
|
||||
// Use startService so onStartCommand receives the STOP action
|
||||
getContext().startService(intent);
|
||||
call.resolve();
|
||||
} catch (Exception e) {
|
||||
call.reject("Failed to stop audio service: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
package tf.monochrome.music;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(BackgroundAudioPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
271
index.html
|
|
@ -211,77 +211,104 @@
|
|||
<button id="toggle-fullscreen-lyrics-btn" class="fullscreen-lyrics-toggle" title="Toggle Lyrics">
|
||||
<use svg="!lucide/mic-vocal.svg" size="24" />
|
||||
</button>
|
||||
<button id="close-fullscreen-cover-btn" title="Close"><use svg="!lucide/x.svg" size="24" /></button>
|
||||
<div class="fullscreen-main-view">
|
||||
<img
|
||||
id="fullscreen-cover-image"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
alt="Album Cover"
|
||||
/>
|
||||
<div class="fullscreen-top-actions">
|
||||
<button id="fs-visualizer-btn" class="fs-visualizer-btn" title="Disable Visualizer">
|
||||
<use svg="!lucide/audio-lines.svg" size="20" />
|
||||
</button>
|
||||
<button id="close-fullscreen-cover-btn" title="Close"><use svg="!lucide/x.svg" size="24" /></button>
|
||||
</div>
|
||||
<div class="fullscreen-shell">
|
||||
<div class="fullscreen-main-view">
|
||||
<div class="fullscreen-media-column">
|
||||
<div class="fullscreen-artwork-card">
|
||||
<img
|
||||
id="fullscreen-cover-image"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
alt="Album Cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fullscreen-track-info">
|
||||
<h2 id="fullscreen-track-title"></h2>
|
||||
<h3 id="fullscreen-track-artist"></h3>
|
||||
<div class="fullscreen-actions">
|
||||
<button id="fs-like-btn" class="btn-icon like-btn" title="Like">
|
||||
<use svg="!lucide/heart.svg" class="heart-icon" size="24" />
|
||||
</button>
|
||||
<button id="fs-add-playlist-btn" class="btn-icon" title="Add to Playlist">
|
||||
<use svg="!lucide/square-pen.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-download-btn" class="btn-icon" title="Download">
|
||||
<use svg="!lucide/download.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-cast-btn" class="btn-icon" title="Cast">
|
||||
<use svg="!lucide/cast.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-queue-btn" class="btn-icon" title="Queue">
|
||||
<use svg="!lucide/list.svg" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<div id="fullscreen-next-track" style="display: none">
|
||||
<span class="label">Up Next: </span>
|
||||
<span class="value"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fullscreen-controls">
|
||||
<div class="fullscreen-progress-container">
|
||||
<span id="fs-current-time">0:00</span>
|
||||
<div id="fs-progress-bar" class="progress-bar">
|
||||
<div id="fs-progress-fill" class="progress-fill"></div>
|
||||
<div class="fullscreen-track-info">
|
||||
<div class="fullscreen-track-text">
|
||||
<h2 id="fullscreen-track-title"></h2>
|
||||
<h3 id="fullscreen-track-artist"></h3>
|
||||
</div>
|
||||
<div class="fullscreen-actions">
|
||||
<button id="fs-like-btn" class="btn-icon like-btn" title="Like">
|
||||
<use svg="!lucide/heart.svg" class="heart-icon" size="24" />
|
||||
</button>
|
||||
<button id="fs-add-playlist-btn" class="btn-icon" title="Add to Playlist">
|
||||
<use svg="!lucide/square-pen.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-download-btn" class="btn-icon" title="Download">
|
||||
<use svg="!lucide/download.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-cast-btn" class="btn-icon" title="Cast">
|
||||
<use svg="!lucide/cast.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-queue-btn" class="btn-icon" title="Queue">
|
||||
<use svg="!lucide/list.svg" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<div id="fullscreen-next-track" style="display: none">
|
||||
<span class="label">Up Next</span>
|
||||
<span class="value"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span id="fs-total-duration">0:00</span>
|
||||
</div>
|
||||
<div class="fullscreen-buttons">
|
||||
<button id="fs-shuffle-btn" title="Shuffle">
|
||||
<use svg="!lucide/shuffle.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-prev-btn" title="Previous">
|
||||
<use svg="!lucide/arrow-left-to-line.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-play-pause-btn" class="play-pause-btn" title="Play">
|
||||
<use svg="./images/play-large.svg" size="32" />
|
||||
</button>
|
||||
<button id="fs-next-btn" title="Next">
|
||||
<use svg="!lucide/arrow-right-to-line.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-repeat-btn" title="Repeat">
|
||||
<use svg="!lucide/repeat.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-quality-btn" class="fs-quality-btn" title="Quality" style="display: none">
|
||||
<use svg="!lucide/pencil-line.svg" size="20" />
|
||||
<span class="fs-quality-label">Auto</span>
|
||||
</button>
|
||||
<div id="fs-quality-menu" class="fs-quality-menu" style="display: none"></div>
|
||||
</div>
|
||||
<div class="fullscreen-volume-container">
|
||||
<button id="fs-volume-btn" class="fs-volume-btn" title="Mute">
|
||||
<use svg="!lucide/volume-1.svg" size="24" />
|
||||
</button>
|
||||
<div id="fs-volume-bar" class="fs-volume-bar">
|
||||
<div id="fs-volume-fill" class="fs-volume-fill"></div>
|
||||
|
||||
<div class="fullscreen-controls">
|
||||
<div class="fullscreen-progress-container">
|
||||
<span id="fs-current-time">0:00</span>
|
||||
<div id="fs-progress-bar" class="progress-bar">
|
||||
<div id="fs-progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<span id="fs-total-duration">0:00</span>
|
||||
</div>
|
||||
<div class="fullscreen-buttons">
|
||||
<button id="fs-shuffle-btn" title="Shuffle">
|
||||
<use svg="!lucide/shuffle.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-prev-btn" title="Previous">
|
||||
<use svg="!lucide/arrow-left-to-line.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-play-pause-btn" class="play-pause-btn" title="Play">
|
||||
<use svg="./images/play-large.svg" size="32" />
|
||||
</button>
|
||||
<button id="fs-next-btn" title="Next">
|
||||
<use svg="!lucide/arrow-right-to-line.svg" size="24" />
|
||||
</button>
|
||||
<button id="fs-repeat-btn" title="Repeat">
|
||||
<use svg="!lucide/repeat.svg" size="24" />
|
||||
</button>
|
||||
<button
|
||||
id="fs-quality-btn"
|
||||
class="fs-quality-btn"
|
||||
title="Quality"
|
||||
style="display: none"
|
||||
>
|
||||
<use svg="!lucide/pencil-line.svg" size="20" />
|
||||
<span class="fs-quality-label">Auto</span>
|
||||
</button>
|
||||
<div id="fs-quality-menu" class="fs-quality-menu" style="display: none"></div>
|
||||
</div>
|
||||
<div class="fullscreen-volume-container">
|
||||
<button id="fs-volume-btn" class="fs-volume-btn" title="Mute">
|
||||
<use svg="!lucide/volume-1.svg" size="24" />
|
||||
</button>
|
||||
<div id="fs-volume-bar" class="fs-volume-bar">
|
||||
<div id="fs-volume-fill" class="fs-volume-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="fullscreen-lyrics-pane" class="fullscreen-lyrics-pane">
|
||||
<div class="fullscreen-lyrics-shell">
|
||||
<div id="fullscreen-lyrics-content" class="fullscreen-lyrics-content">
|
||||
<div class="fullscreen-lyrics-empty">Lyrics appear here.</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4118,6 +4145,7 @@
|
|||
<!-- Mode Toggle + How To -->
|
||||
<div class="autoeq-mode-row">
|
||||
<div class="autoeq-mode-toggle">
|
||||
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
|
||||
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
||||
<button class="autoeq-mode-btn" data-mode="parametric">
|
||||
Parametric EQ
|
||||
|
|
@ -4254,6 +4282,66 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
|
||||
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
|
||||
<div class="graphic-eq-preset-row">
|
||||
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
|
||||
>Preset</label
|
||||
>
|
||||
<select
|
||||
id="legacy-graphic-eq-preset-select"
|
||||
class="graphic-eq-preset-select"
|
||||
>
|
||||
<option value="">Custom</option>
|
||||
<option value="flat">Flat</option>
|
||||
<option value="bass_boost">Bass Boost</option>
|
||||
<option value="bass_reducer">Bass Reducer</option>
|
||||
<option value="treble_boost">Treble Boost</option>
|
||||
<option value="treble_reducer">Treble Reducer</option>
|
||||
<option value="vocal_boost">Vocal Boost</option>
|
||||
<option value="loudness">Loudness</option>
|
||||
<option value="rock">Rock</option>
|
||||
<option value="pop">Pop</option>
|
||||
<option value="classical">Classical</option>
|
||||
<option value="jazz">Jazz</option>
|
||||
<option value="electronic">Electronic</option>
|
||||
<option value="hip_hop">Hip-Hop</option>
|
||||
<option value="r_and_b">R&B</option>
|
||||
<option value="acoustic">Acoustic</option>
|
||||
<option value="podcast">Speech</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
|
||||
<!-- 16 vertical sliders generated by JS -->
|
||||
</div>
|
||||
<div class="graphic-eq-bottom-row">
|
||||
<div class="graphic-eq-preamp">
|
||||
<label
|
||||
for="legacy-graphic-eq-preamp-slider"
|
||||
class="graphic-eq-preamp-label"
|
||||
>Preamp</label
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
id="legacy-graphic-eq-preamp-slider"
|
||||
class="graphic-eq-preamp-slider"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
/>
|
||||
<span
|
||||
class="graphic-eq-preamp-value"
|
||||
id="legacy-graphic-eq-preamp-value"
|
||||
>0 dB</span
|
||||
>
|
||||
</div>
|
||||
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency Response Graph -->
|
||||
<div class="autoeq-graph-section">
|
||||
<div class="autoeq-graph-header">
|
||||
|
|
@ -4300,29 +4388,42 @@
|
|||
|
||||
<!-- Database Browser -->
|
||||
<div class="autoeq-database-section" id="autoeq-database-section">
|
||||
<div class="autoeq-database-header">
|
||||
<div class="autoeq-database-header" id="autoeq-database-toggle">
|
||||
<div>
|
||||
<h4 class="autoeq-database-title">Database</h4>
|
||||
<span class="autoeq-database-subtitle">AutoEq Repo</span>
|
||||
</div>
|
||||
<span class="autoeq-database-count" id="autoeq-database-count"></span>
|
||||
</div>
|
||||
<div class="autoeq-database-search">
|
||||
<use svg="!lucide/search.svg" size="16" />
|
||||
<input
|
||||
type="text"
|
||||
id="autoeq-headphone-search"
|
||||
placeholder="Search model (e.g. HD 600)..."
|
||||
autocomplete="off"
|
||||
aria-label="Search headphone model"
|
||||
/>
|
||||
</div>
|
||||
<div class="autoeq-database-content">
|
||||
<div class="autoeq-database-list" id="autoeq-database-list">
|
||||
<!-- Dynamically populated -->
|
||||
<div class="autoeq-database-header-right">
|
||||
<span class="autoeq-database-count" id="autoeq-database-count"></span>
|
||||
<button
|
||||
class="autoeq-collapse-btn"
|
||||
id="autoeq-database-collapse"
|
||||
aria-label="Collapse database"
|
||||
aria-expanded="true"
|
||||
aria-controls="autoeq-database-body"
|
||||
>
|
||||
<use svg="!lucide/chevron-up.svg" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
|
||||
<!-- A-Z generated by JS -->
|
||||
</div>
|
||||
<div class="autoeq-database-body" id="autoeq-database-body">
|
||||
<div class="autoeq-database-search">
|
||||
<use svg="!lucide/search.svg" size="16" />
|
||||
<input
|
||||
type="text"
|
||||
id="autoeq-headphone-search"
|
||||
placeholder="Search model (e.g. HD 600)..."
|
||||
autocomplete="off"
|
||||
aria-label="Search headphone model"
|
||||
/>
|
||||
</div>
|
||||
<div class="autoeq-database-content">
|
||||
<div class="autoeq-database-list" id="autoeq-database-list">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
|
||||
<!-- A-Z generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import UIKit
|
||||
import AVFoundation
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
|
|
@ -7,10 +8,82 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
configureAudioSession()
|
||||
return true
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
// .playback keeps audio alive when the app is backgrounded or the screen locks
|
||||
try session.setCategory(.playback, mode: .default, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("[AudioSession] Failed to configure: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Handle audio interruptions (phone calls, Siri, alarms, etc.)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAudioInterruption),
|
||||
name: AVAudioSession.interruptionNotification,
|
||||
object: session
|
||||
)
|
||||
|
||||
// Handle route changes (headphones unplugged, Bluetooth disconnect, etc.)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange),
|
||||
name: AVAudioSession.routeChangeNotification,
|
||||
object: session
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleAudioInterruption(notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
// Interruption began — system pauses audio automatically
|
||||
break
|
||||
case .ended:
|
||||
// Interruption ended — reactivate session so playback can resume
|
||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("[AudioSession] Failed to reactivate after interruption: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
if reason == .oldDeviceUnavailable {
|
||||
// Headphones/Bluetooth disconnected — reactivate session to keep background alive
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("[AudioSession] Failed to reactivate after route change: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
|
|
|
|||
|
|
@ -47,5 +47,9 @@
|
|||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
1613
js/HiFi.ts
19
js/api.js
|
|
@ -1073,18 +1073,20 @@ export class LosslessAPI {
|
|||
entries.forEach((entry) => scan(entry, visited));
|
||||
scan(primaryData, visited);
|
||||
|
||||
const matchesArtistId = (item) => {
|
||||
const candidateIds = [
|
||||
item.artist?.id,
|
||||
...(Array.isArray(item.artists) ? item.artists.map((a) => a.id) : []),
|
||||
].filter((id) => id != null);
|
||||
return candidateIds.some((id) => Number(id) === Number(artistId));
|
||||
};
|
||||
|
||||
if (!options.lightweight) {
|
||||
try {
|
||||
const videoSearch = await this.searchVideos(artist.name);
|
||||
if (videoSearch && videoSearch.items) {
|
||||
const numericArtistId = Number(artistId);
|
||||
for (const item of videoSearch.items) {
|
||||
const itemArtistId = item.artist?.id;
|
||||
const matchesArtist =
|
||||
itemArtistId === numericArtistId ||
|
||||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
|
||||
|
||||
if (matchesArtist && !videoMap.has(item.id)) {
|
||||
if (matchesArtistId(item) && !videoMap.has(item.id)) {
|
||||
videoMap.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
|
@ -1094,7 +1096,7 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
const rawReleases = Array.from(albumMap.values());
|
||||
const rawReleases = Array.from(albumMap.values()).filter(matchesArtistId);
|
||||
const allReleases = this.deduplicateAlbums(rawReleases).sort(
|
||||
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
|
@ -1103,6 +1105,7 @@ export class LosslessAPI {
|
|||
const albums = allReleases.filter((a) => !eps.includes(a));
|
||||
|
||||
const topTracks = Array.from(trackMap.values())
|
||||
.filter(matchesArtistId)
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 15);
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,15 @@ class AudioContextManager {
|
|||
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
||||
this._graphChangeCallbacks = [];
|
||||
|
||||
// --- Graphic EQ (16-band, separate chain) ---
|
||||
this.geqFilters = [];
|
||||
this.geqPreampNode = null;
|
||||
this.geqOutputNode = null;
|
||||
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
|
||||
this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
|
||||
this.geqGains = equalizerSettings.getGraphicEqGains();
|
||||
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
|
||||
|
||||
// Load saved settings
|
||||
this._loadSettings();
|
||||
}
|
||||
|
|
@ -308,17 +317,12 @@ class AudioContextManager {
|
|||
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
|
||||
|
||||
try {
|
||||
this.audioContext = new AudioContext(highResOptions);
|
||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
console.log(`[AudioContext] Created: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch {
|
||||
try {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
} catch {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
if (!this.sources.has(audioElement)) {
|
||||
|
|
@ -331,6 +335,7 @@ class AudioContextManager {
|
|||
this.analyser.smoothingTimeConstant = 0.7;
|
||||
|
||||
this._createEQ();
|
||||
this._createGraphicEQ();
|
||||
|
||||
this.outputNode = this.audioContext.createGain();
|
||||
this.outputNode.gain.value = 1;
|
||||
|
|
@ -342,6 +347,21 @@ class AudioContextManager {
|
|||
|
||||
this._connectGraph();
|
||||
|
||||
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
||||
this.audioContext.addEventListener('statechange', () => {
|
||||
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
||||
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
|
||||
// Use a short delay to let the system settle before resuming
|
||||
setTimeout(() => {
|
||||
if (this.audioContext && this.audioContext.state !== 'running' && this.source) {
|
||||
this.audioContext.resume().catch((e) => {
|
||||
console.warn('[AudioContext] Auto-resume failed:', e);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Init failed:', e);
|
||||
|
|
@ -380,65 +400,76 @@ class AudioContextManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Connect the audio graph based on EQ and mono audio state
|
||||
* Connect the audio graph based on EQ and mono audio state.
|
||||
* Uses connect-before-disconnect ordering to avoid audio dropouts:
|
||||
* the new chain is wired up first, then the old connections are torn down.
|
||||
*/
|
||||
_connectGraph() {
|
||||
if (!this.isInitialized || !this.source || !this.audioContext) return;
|
||||
|
||||
try {
|
||||
// Disconnect everything first
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
this.outputNode.disconnect();
|
||||
if (this.volumeNode) {
|
||||
this.volumeNode.disconnect();
|
||||
}
|
||||
this.analyser.disconnect();
|
||||
// Ensure graphic EQ nodes exist
|
||||
if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) {
|
||||
this._createGraphicEQ();
|
||||
}
|
||||
|
||||
if (this.monoMergerNode) {
|
||||
try {
|
||||
this.monoMergerNode.disconnect();
|
||||
} catch {
|
||||
// Ignore if not connected
|
||||
// Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest
|
||||
const connectTail = (lastNode) => {
|
||||
if (this.isGraphicEQEnabled && this.geqFilters.length > 0) {
|
||||
lastNode.connect(this.geqPreampNode);
|
||||
this.geqPreampNode.connect(this.geqFilters[0]);
|
||||
for (let i = 0; i < this.geqFilters.length - 1; i++) {
|
||||
this.geqFilters[i].connect(this.geqFilters[i + 1]);
|
||||
}
|
||||
this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode);
|
||||
this.geqOutputNode.connect(this.analyser);
|
||||
} else {
|
||||
lastNode.connect(this.analyser);
|
||||
}
|
||||
this.analyser.connect(this.volumeNode);
|
||||
this.volumeNode.connect(this.audioContext.destination);
|
||||
};
|
||||
|
||||
try {
|
||||
// Ensure mono gain node exists if needed
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) {
|
||||
this.monoGainNode = this.audioContext.createGain();
|
||||
this.monoGainNode.gain.value = 0.5;
|
||||
}
|
||||
|
||||
// --- 1. Disconnect all existing connections ---
|
||||
const safeDisconnect = (node) => {
|
||||
try {
|
||||
node?.disconnect();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
};
|
||||
safeDisconnect(this.source);
|
||||
safeDisconnect(this.monoGainNode);
|
||||
safeDisconnect(this.monoMergerNode);
|
||||
safeDisconnect(this.preampNode);
|
||||
this.filters.forEach(safeDisconnect);
|
||||
safeDisconnect(this.outputNode);
|
||||
safeDisconnect(this.geqPreampNode);
|
||||
this.geqFilters.forEach(safeDisconnect);
|
||||
safeDisconnect(this.geqOutputNode);
|
||||
safeDisconnect(this.analyser);
|
||||
safeDisconnect(this.volumeNode);
|
||||
|
||||
// --- 2. Reconnect the graph ---
|
||||
let lastNode = this.source;
|
||||
|
||||
// Apply mono audio if enabled
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||
// Reuse persistent gain node to avoid leaking AudioNodes
|
||||
if (!this.monoGainNode) {
|
||||
this.monoGainNode = this.audioContext.createGain();
|
||||
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||
}
|
||||
try {
|
||||
this.monoGainNode.disconnect();
|
||||
} catch {
|
||||
/* not connected */
|
||||
}
|
||||
|
||||
// Connect source to mono gain
|
||||
this.source.connect(this.monoGainNode);
|
||||
|
||||
// Connect mono gain to both inputs of the merger
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||
|
||||
lastNode = this.monoMergerNode;
|
||||
console.log('[AudioContext] Mono audio enabled');
|
||||
}
|
||||
|
||||
if (this.isEQEnabled && this.filters.length > 0) {
|
||||
// EQ enabled: lastNode -> preamp -> EQ filters -> output -> analyser -> volume -> destination
|
||||
// Connect filter chain
|
||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||
this.filters[i].connect(this.filters[i + 1]);
|
||||
}
|
||||
// Connect preamp to first filter
|
||||
if (this.preampNode) {
|
||||
lastNode.connect(this.preampNode);
|
||||
this.preampNode.connect(this.filters[0]);
|
||||
|
|
@ -446,22 +477,15 @@ class AudioContextManager {
|
|||
lastNode.connect(this.filters[0]);
|
||||
}
|
||||
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||
this.outputNode.connect(this.analyser);
|
||||
this.analyser.connect(this.volumeNode);
|
||||
this.volumeNode.connect(this.audioContext.destination);
|
||||
console.log('[AudioContext] EQ connected');
|
||||
connectTail(this.outputNode);
|
||||
} else {
|
||||
// EQ disabled: lastNode -> analyser -> volume -> destination
|
||||
lastNode.connect(this.analyser);
|
||||
this.analyser.connect(this.volumeNode);
|
||||
this.volumeNode.connect(this.audioContext.destination);
|
||||
connectTail(lastNode);
|
||||
}
|
||||
|
||||
// Notify visualizers that graph has been reconnected
|
||||
this._notifyGraphChange();
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Failed to connect graph:', e);
|
||||
// Fallback: direct connection
|
||||
try {
|
||||
this.source.connect(this.audioContext.destination);
|
||||
} catch {
|
||||
|
|
@ -815,11 +839,22 @@ class AudioContextManager {
|
|||
this.currentQs = newQs;
|
||||
this.currentGains = newGains;
|
||||
|
||||
// Rebuild EQ so _createEQ picks up the new types/Qs
|
||||
if (this.isInitialized && this.audioContext) {
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
// If filter count matches, update params in-place (no graph rebuild)
|
||||
if (this.filters.length === count) {
|
||||
const now = this.audioContext.currentTime;
|
||||
this.filters.forEach((filter, i) => {
|
||||
filter.type = newTypes[i] || 'peaking';
|
||||
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
|
||||
filter.gain.setTargetAtTime(newGains[i], now, 0.005);
|
||||
filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
|
||||
});
|
||||
} else {
|
||||
// Band count changed — must rebuild
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preamp (skip if caller manages preamp externally)
|
||||
|
|
@ -955,6 +990,97 @@ class AudioContextManager {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Graphic EQ (16-band, independent chain)
|
||||
// ========================================
|
||||
|
||||
_createGraphicEQ() {
|
||||
if (!this.audioContext) return;
|
||||
this.geqPreampNode = this.audioContext.createGain();
|
||||
const gainValue = Math.pow(10, (this.geqPreamp || 0) / 20);
|
||||
this.geqPreampNode.gain.value = gainValue;
|
||||
|
||||
this.geqOutputNode = this.audioContext.createGain();
|
||||
this.geqOutputNode.gain.value = 1;
|
||||
|
||||
this.geqFilters = this.geqFrequencies.map((freq, i) => {
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
filter.type = 'peaking';
|
||||
filter.frequency.value = freq;
|
||||
filter.Q.value = 2.5; // constant Q for 16-band
|
||||
filter.gain.value = this.geqGains[i] || 0;
|
||||
return filter;
|
||||
});
|
||||
}
|
||||
|
||||
_destroyGraphicEQ() {
|
||||
this.geqFilters.forEach((f) => {
|
||||
try {
|
||||
f.disconnect();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
this.geqFilters = [];
|
||||
if (this.geqPreampNode) {
|
||||
try {
|
||||
this.geqPreampNode.disconnect();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
this.geqPreampNode = null;
|
||||
}
|
||||
if (this.geqOutputNode) {
|
||||
try {
|
||||
this.geqOutputNode.disconnect();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
this.geqOutputNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleGraphicEQ(enabled) {
|
||||
this.isGraphicEQEnabled = enabled;
|
||||
equalizerSettings.setGraphicEqEnabled(enabled);
|
||||
if (this.isInitialized) {
|
||||
this._connectGraph();
|
||||
}
|
||||
}
|
||||
|
||||
setGraphicEqBandGain(bandIndex, gainDb) {
|
||||
if (bandIndex < 0 || bandIndex >= 16) return;
|
||||
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
|
||||
if (this.geqFilters[bandIndex] && this.audioContext) {
|
||||
const now = this.audioContext.currentTime;
|
||||
this.geqFilters[bandIndex].gain.setTargetAtTime(this.geqGains[bandIndex], now, 0.01);
|
||||
}
|
||||
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
||||
}
|
||||
|
||||
setGraphicEqAllGains(gains) {
|
||||
if (!Array.isArray(gains)) return;
|
||||
const now = this.audioContext?.currentTime || 0;
|
||||
gains.forEach((g, i) => {
|
||||
if (i >= 16) return;
|
||||
this.geqGains[i] = Math.max(-30, Math.min(30, g));
|
||||
if (this.geqFilters[i]) {
|
||||
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
|
||||
}
|
||||
});
|
||||
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
||||
}
|
||||
|
||||
setGraphicEqPreamp(db) {
|
||||
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||
if (this.geqPreampNode && this.audioContext) {
|
||||
const gainValue = Math.pow(10, this.geqPreamp / 20);
|
||||
const now = this.audioContext.currentTime;
|
||||
this.geqPreampNode.gain.setTargetAtTime(gainValue, now, 0.01);
|
||||
}
|
||||
equalizerSettings.setGraphicEqPreamp(this.geqPreamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export { default as SVG_CLOCK } from '!lucide/clock.svg?svg&icon';
|
|||
export { default as SVG_CLOSE } from '!lucide/x.svg?svg&icon';
|
||||
export { default as SVG_DISC } from '!lucide/disc.svg?svg&icon';
|
||||
export { default as SVG_DOWNLOAD } from '!lucide/download.svg?svg&icon';
|
||||
export { default as SVG_EYE } from '!lucide/eye.svg?svg&icon';
|
||||
export { default as SVG_EYE_OFF } from '!lucide/eye-off.svg?svg&icon';
|
||||
export { default as SVG_EQUAL } from '!lucide/equal.svg?svg&icon';
|
||||
export { default as SVG_FACEBOOK } from '../images/facebook.svg?svg&icon';
|
||||
export { default as SVG_FOLDER_PLUS } from '!lucide/folder-plus.svg?svg&icon';
|
||||
|
|
|
|||
69
js/lyrics.js
|
|
@ -980,6 +980,74 @@ themeObserver.observe(document.documentElement, {
|
|||
attributeFilter: ['data-theme', 'style'],
|
||||
});
|
||||
|
||||
function applyFullscreenLyricsShadowTweaks(amLyrics, container) {
|
||||
if (!amLyrics || container?.id !== 'fullscreen-lyrics-content') return;
|
||||
|
||||
const injectStyle = () => {
|
||||
const root = amLyrics.shadowRoot;
|
||||
if (!root) return false;
|
||||
|
||||
let styleEl = root.getElementById('monochrome-fullscreen-lyrics-tweaks');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'monochrome-fullscreen-lyrics-tweaks';
|
||||
root.appendChild(styleEl);
|
||||
}
|
||||
|
||||
styleEl.textContent = `
|
||||
.lyrics-container {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
.lyrics-container::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.lyrics-line {
|
||||
transition:
|
||||
opacity 0.42s ease,
|
||||
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms),
|
||||
filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
}
|
||||
|
||||
.lyrics-line-container {
|
||||
transition:
|
||||
transform 0.72s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease !important;
|
||||
}
|
||||
|
||||
.lyrics-line.active .lyrics-line-container,
|
||||
.lyrics-line.pre-active .lyrics-line-container {
|
||||
transition:
|
||||
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 0.22s ease,
|
||||
color 0.22s ease !important;
|
||||
}
|
||||
`;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (injectStyle()) return;
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 24;
|
||||
const tryInject = () => {
|
||||
if (injectStyle()) return;
|
||||
attempts += 1;
|
||||
if (attempts < maxAttempts) {
|
||||
requestAnimationFrame(tryInject);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryInject);
|
||||
}
|
||||
|
||||
async function renderLyricsComponent(container, track, audioPlayer, lyricsManager) {
|
||||
container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
|
||||
|
||||
|
|
@ -1022,6 +1090,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
|
|||
amLyrics.style.width = '100%';
|
||||
|
||||
container.appendChild(amLyrics);
|
||||
applyFullscreenLyricsShadowTweaks(amLyrics, container);
|
||||
|
||||
lyricsManager.setupLyricsObserver(amLyrics);
|
||||
|
||||
|
|
|
|||
42
js/player.js
|
|
@ -161,9 +161,13 @@ export class Player {
|
|||
await this.saveQueueState();
|
||||
});
|
||||
|
||||
// Handle visibility change for iOS - AudioContext gets suspended when screen locks
|
||||
// Handle visibility change - AudioContext can be suspended when backgrounded
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
const el = this.activeElement;
|
||||
if (document.visibilityState === 'hidden' && !el.paused) {
|
||||
// Proactively resume context when going to background to prevent suspension
|
||||
void audioContextManager.resume();
|
||||
}
|
||||
if (document.visibilityState === 'visible' && !el.paused) {
|
||||
// Ensure audio context is resumed when user returns to the app
|
||||
if (!audioContextManager.isReady()) {
|
||||
|
|
@ -2089,7 +2093,41 @@ export class Player {
|
|||
|
||||
updateMediaSessionPlaybackState() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing';
|
||||
const isPlaying = !this.activeElement.paused;
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
|
||||
// Start/stop Android foreground service to prevent background audio throttling
|
||||
this._updateBackgroundAudioService(isPlaying);
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android (Capacitor), start or stop the foreground service that keeps
|
||||
* the WebView alive so Web Audio EQ processing isn't throttled.
|
||||
*/
|
||||
_updateBackgroundAudioService(isPlaying) {
|
||||
if (this._bgAudioPending) return;
|
||||
this._bgAudioPending = true;
|
||||
|
||||
// Lazy-load Capacitor core; no-op on web/iOS
|
||||
void (async () => {
|
||||
try {
|
||||
const { Capacitor } = await import('@capacitor/core');
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
const { registerPlugin } = await import('@capacitor/core');
|
||||
if (!this._bgAudioPlugin) {
|
||||
this._bgAudioPlugin = registerPlugin('BackgroundAudio');
|
||||
}
|
||||
if (isPlaying) {
|
||||
await this._bgAudioPlugin.start();
|
||||
} else {
|
||||
await this._bgAudioPlugin.stop();
|
||||
}
|
||||
} catch {
|
||||
// Not running in Capacitor or plugin unavailable — ignore
|
||||
} finally {
|
||||
this._bgAudioPending = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
updateMediaSessionPositionState() {
|
||||
|
|
|
|||
480
js/settings.js
|
|
@ -57,6 +57,8 @@ async function getButterchurnPresets(...args) {
|
|||
|
||||
// Module-level state for AutoEQ (persists across re-initializations)
|
||||
let _autoeqIndex = [];
|
||||
let _graphAbortController = null;
|
||||
let _graphResizeObserver = null;
|
||||
|
||||
export async function initializeSettings(scrobbler, player, api, ui) {
|
||||
// Restore last active settings tab
|
||||
|
|
@ -1231,6 +1233,154 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 16-Band Graphic Equalizer (Legacy EQ mode)
|
||||
// ========================================
|
||||
const GEQ_LABELS = [
|
||||
'25',
|
||||
'40',
|
||||
'63',
|
||||
'100',
|
||||
'160',
|
||||
'250',
|
||||
'400',
|
||||
'630',
|
||||
'1K',
|
||||
'1.6K',
|
||||
'2.5K',
|
||||
'4K',
|
||||
'6.3K',
|
||||
'10K',
|
||||
'16K',
|
||||
'20K',
|
||||
];
|
||||
const geqBandsContainer = document.getElementById('graphic-eq-bands');
|
||||
const geqPreampSlider = document.getElementById('graphic-eq-preamp-slider');
|
||||
const geqPreampValue = document.getElementById('graphic-eq-preamp-value');
|
||||
const geqPresetSelect = document.getElementById('graphic-eq-preset-select');
|
||||
const geqResetBtn = document.getElementById('graphic-eq-reset-btn');
|
||||
|
||||
const legacyGeqBandsContainer = document.getElementById('legacy-graphic-eq-bands');
|
||||
const legacyGeqPreampSlider = document.getElementById('legacy-graphic-eq-preamp-slider');
|
||||
const legacyGeqPreampValue = document.getElementById('legacy-graphic-eq-preamp-value');
|
||||
const legacyGeqPresetSelect = document.getElementById('legacy-graphic-eq-preset-select');
|
||||
const legacyGeqResetBtn = document.getElementById('legacy-graphic-eq-reset-btn');
|
||||
|
||||
const geqPreampSliders = [geqPreampSlider, legacyGeqPreampSlider].filter(Boolean);
|
||||
const geqPreampValues = [geqPreampValue, legacyGeqPreampValue].filter(Boolean);
|
||||
const geqPresetSelects = [geqPresetSelect, legacyGeqPresetSelect].filter(Boolean);
|
||||
|
||||
let geqGains = equalizerSettings.getGraphicEqGains() || new Array(16).fill(0);
|
||||
let geqPreamp = equalizerSettings.getGraphicEqPreamp() || 0;
|
||||
const geqRange = equalizerSettings.getRange();
|
||||
|
||||
// Sync all slider UIs across both containers
|
||||
const geqSyncAllSliders = () => {
|
||||
geqGains.forEach((g, i) => {
|
||||
['geq', 'legacy-geq'].forEach((prefix) => {
|
||||
const sl = document.getElementById(`${prefix}-slider-${i}`);
|
||||
const vl = document.getElementById(`${prefix}-value-${i}`);
|
||||
if (sl) sl.value = g;
|
||||
if (vl) vl.textContent = `${g > 0 ? '+' : ''}${g.toFixed(1)}`;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Build 16 vertical slider bands into a container
|
||||
const buildGeqBands = (container, idPrefix) => {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
GEQ_LABELS.forEach((_label, i) => {
|
||||
const band = document.createElement('div');
|
||||
band.className = 'graphic-eq-band';
|
||||
|
||||
const valueLabel = document.createElement('span');
|
||||
valueLabel.className = 'graphic-eq-band-value';
|
||||
valueLabel.textContent = `${geqGains[i] > 0 ? '+' : ''}${geqGains[i].toFixed(1)}`;
|
||||
valueLabel.id = `${idPrefix}-value-${i}`;
|
||||
|
||||
const sliderWrap = document.createElement('div');
|
||||
sliderWrap.className = 'graphic-eq-band-slider-wrap';
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = geqRange.min;
|
||||
slider.max = geqRange.max;
|
||||
slider.step = '0.1';
|
||||
slider.value = geqGains[i];
|
||||
slider.id = `${idPrefix}-slider-${i}`;
|
||||
slider.setAttribute('aria-label', `${GEQ_LABELS[i]} Hz`);
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
const gain = parseFloat(slider.value);
|
||||
geqGains[i] = gain;
|
||||
equalizerSettings.setGraphicEqGains(geqGains);
|
||||
audioContextManager.setGraphicEqBandGain(i, gain);
|
||||
geqSyncAllSliders();
|
||||
geqPresetSelects.forEach((s) => (s.value = ''));
|
||||
});
|
||||
|
||||
sliderWrap.appendChild(slider);
|
||||
|
||||
const freqLabel = document.createElement('span');
|
||||
freqLabel.className = 'graphic-eq-band-label';
|
||||
freqLabel.textContent = GEQ_LABELS[i];
|
||||
|
||||
band.appendChild(valueLabel);
|
||||
band.appendChild(sliderWrap);
|
||||
band.appendChild(freqLabel);
|
||||
container.appendChild(band);
|
||||
});
|
||||
};
|
||||
|
||||
buildGeqBands(geqBandsContainer, 'geq');
|
||||
buildGeqBands(legacyGeqBandsContainer, 'legacy-geq');
|
||||
|
||||
// Wire up preamp sliders
|
||||
geqPreampSliders.forEach((slider) => {
|
||||
slider.value = geqPreamp;
|
||||
slider.addEventListener('input', () => {
|
||||
geqPreamp = parseFloat(slider.value);
|
||||
const text = `${geqPreamp.toFixed(1)} dB`;
|
||||
geqPreampValues.forEach((v) => (v.textContent = text));
|
||||
geqPreampSliders.forEach((s) => {
|
||||
if (s !== slider) s.value = geqPreamp;
|
||||
});
|
||||
equalizerSettings.setGraphicEqPreamp(geqPreamp);
|
||||
audioContextManager.setGraphicEqPreamp(geqPreamp);
|
||||
});
|
||||
});
|
||||
geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp} dB`));
|
||||
|
||||
// Wire up preset selects
|
||||
geqPresetSelects.forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
const key = select.value;
|
||||
if (!key) return;
|
||||
const presets = getPresetsForBandCount(16);
|
||||
const preset = presets[key];
|
||||
if (!preset) return;
|
||||
geqGains = [...preset.gains];
|
||||
equalizerSettings.setGraphicEqGains(geqGains);
|
||||
audioContextManager.setGraphicEqAllGains(geqGains);
|
||||
geqSyncAllSliders();
|
||||
geqPresetSelects.forEach((s) => {
|
||||
if (s !== select) s.value = key;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up reset buttons
|
||||
[geqResetBtn, legacyGeqResetBtn].filter(Boolean).forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
geqGains = new Array(16).fill(0);
|
||||
equalizerSettings.setGraphicEqGains(geqGains);
|
||||
audioContextManager.setGraphicEqAllGains(geqGains);
|
||||
geqSyncAllSliders();
|
||||
geqPresetSelects.forEach((s) => (s.value = 'flat'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Precision AutoEQ - Redesigned Equalizer
|
||||
// ========================================
|
||||
|
|
@ -1362,6 +1512,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
/**
|
||||
* Draw the frequency response graph with Original, Target, and Corrected curves
|
||||
*/
|
||||
let _drawGraphRafId = null;
|
||||
const scheduleDrawAutoEQGraph = () => {
|
||||
if (_drawGraphRafId) return;
|
||||
_drawGraphRafId = requestAnimationFrame(() => {
|
||||
_drawGraphRafId = null;
|
||||
drawAutoEQGraph();
|
||||
});
|
||||
};
|
||||
|
||||
const drawAutoEQGraph = () => {
|
||||
if (!autoeqCanvas) return;
|
||||
const activeBands = getActiveBands();
|
||||
|
|
@ -1859,18 +2018,82 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
if (autoeqCanvas) {
|
||||
autoeqCanvas.addEventListener('mousedown', (e) => {
|
||||
const coords = getCanvasCoords(e);
|
||||
const nodeIdx = findClosestNode(coords.x, coords.y, 18);
|
||||
let nodeIdx = findClosestNode(coords.x, coords.y, 18);
|
||||
if (nodeIdx >= 0) {
|
||||
// Clicked directly on a node - start dragging
|
||||
draggedNode = nodeIdx;
|
||||
autoeqCanvas.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Clicked empty space - find nearest node (no threshold) and snap it
|
||||
nodeIdx = findClosestNode(coords.x, coords.y, Infinity);
|
||||
if (nodeIdx >= 0) {
|
||||
const bands = getActiveBands();
|
||||
if (bands && bands[nodeIdx]) {
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
const padLeft = 40,
|
||||
padRight = 10,
|
||||
padTop = 10,
|
||||
padBottom = 30;
|
||||
const w = rect.width - padLeft - padRight;
|
||||
const h = rect.height - padTop - padBottom;
|
||||
const isParam = currentMode === 'parametric';
|
||||
const dbCenter = isParam ? 0 : 75;
|
||||
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
||||
const dbMin = dbCenter - dbHalf;
|
||||
const dbMax = dbCenter + dbHalf;
|
||||
|
||||
// Snap frequency to click position
|
||||
const freq = xToFreq(coords.x - padLeft, w);
|
||||
bands[nodeIdx].freq = Math.max(20, Math.min(20000, freq));
|
||||
|
||||
// Snap gain to click position
|
||||
if (isParam) {
|
||||
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
bands[nodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
||||
} else {
|
||||
const corrGain = interpolate(bands[nodeIdx].freq, autoeqCorrectedCurve || []);
|
||||
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
const gainDelta = newDb - corrGain;
|
||||
bands[nodeIdx].gain = Math.max(-30, Math.min(30, bands[nodeIdx].gain + gainDelta * 0.3));
|
||||
}
|
||||
|
||||
draggedNode = nodeIdx;
|
||||
autoeqCanvas.style.cursor = 'grabbing';
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
renderBandControls(bands);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
autoeqCanvas.addEventListener('mousemove', (e) => {
|
||||
const coords = getCanvasCoords(e);
|
||||
const bands = getActiveBands();
|
||||
if (draggedNode !== null && bands) {
|
||||
// Helper to compute canvas-relative coords from any mouse event (even outside the canvas)
|
||||
const getCanvasCoordsFromEvent = (e) => {
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
// Clean up previous document-level listeners and observer on re-initialization
|
||||
if (_graphAbortController) _graphAbortController.abort();
|
||||
_graphAbortController = new AbortController();
|
||||
const graphSignal = _graphAbortController.signal;
|
||||
if (_graphResizeObserver) {
|
||||
_graphResizeObserver.disconnect();
|
||||
_graphResizeObserver = null;
|
||||
}
|
||||
|
||||
// Document-level mousemove so dragging continues outside the canvas
|
||||
document.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
if (draggedNode === null) return;
|
||||
const bands = getActiveBands();
|
||||
if (!bands) return;
|
||||
|
||||
const coords = getCanvasCoordsFromEvent(e);
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
const padLeft = 40,
|
||||
padRight = 10,
|
||||
|
|
@ -1907,34 +2130,49 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
graphAnimFrame = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
{ signal: graphSignal }
|
||||
);
|
||||
|
||||
// Canvas-only mousemove for hover cursor changes (when not dragging)
|
||||
autoeqCanvas.addEventListener('mousemove', (e) => {
|
||||
if (draggedNode !== null) return; // dragging is handled by document listener
|
||||
const coords = getCanvasCoords(e);
|
||||
const padLeft = 40;
|
||||
if (coords.x <= padLeft + 10) {
|
||||
autoeqCanvas.style.cursor = 'ns-resize';
|
||||
if (hoveredNode !== null) {
|
||||
hoveredNode = null;
|
||||
drawAutoEQGraph();
|
||||
}
|
||||
} else {
|
||||
const padLeft = 40;
|
||||
if (coords.x <= padLeft + 10) {
|
||||
autoeqCanvas.style.cursor = 'ns-resize';
|
||||
if (hoveredNode !== null) {
|
||||
hoveredNode = null;
|
||||
drawAutoEQGraph();
|
||||
}
|
||||
} else {
|
||||
const newHovered = findClosestNode(coords.x, coords.y, 18);
|
||||
if (newHovered !== hoveredNode) {
|
||||
hoveredNode = newHovered;
|
||||
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
||||
drawAutoEQGraph();
|
||||
}
|
||||
const newHovered = findClosestNode(coords.x, coords.y, 18);
|
||||
if (newHovered !== hoveredNode) {
|
||||
hoveredNode = newHovered;
|
||||
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
||||
drawAutoEQGraph();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
autoeqCanvas.addEventListener('mouseup', () => {
|
||||
draggedNode = null;
|
||||
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
||||
});
|
||||
// Document-level mouseup so drag ends even if cursor is outside the canvas
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
if (draggedNode !== null) {
|
||||
draggedNode = null;
|
||||
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
||||
}
|
||||
},
|
||||
{ signal: graphSignal }
|
||||
);
|
||||
|
||||
autoeqCanvas.addEventListener('mouseleave', () => {
|
||||
draggedNode = null;
|
||||
// Only reset hover state, NOT drag state (drag continues outside canvas)
|
||||
hoveredNode = null;
|
||||
autoeqCanvas.style.cursor = 'crosshair';
|
||||
if (draggedNode === null) {
|
||||
autoeqCanvas.style.cursor = 'crosshair';
|
||||
}
|
||||
drawAutoEQGraph();
|
||||
});
|
||||
|
||||
|
|
@ -2038,74 +2276,131 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
{ passive: false }
|
||||
);
|
||||
|
||||
// Touch support
|
||||
// Touch support - snap nearest node on empty space touch, continue drag outside canvas
|
||||
let touchNodeIdx = -1;
|
||||
autoeqCanvas.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
const touch = e.touches[0];
|
||||
const coords = {
|
||||
x: touch.clientX - autoeqCanvas.getBoundingClientRect().left,
|
||||
y: touch.clientY - autoeqCanvas.getBoundingClientRect().top,
|
||||
};
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
||||
touchNodeIdx = findClosestNode(coords.x, coords.y, 25);
|
||||
if (touchNodeIdx >= 0) {
|
||||
draggedNode = touchNodeIdx;
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Snap nearest node to touch position
|
||||
touchNodeIdx = findClosestNode(coords.x, coords.y, Infinity);
|
||||
if (touchNodeIdx >= 0) {
|
||||
const bands = getActiveBands();
|
||||
if (bands && bands[touchNodeIdx]) {
|
||||
const padLeft = 40,
|
||||
padRight = 10,
|
||||
padTop = 10,
|
||||
padBottom = 30;
|
||||
const w = rect.width - padLeft - padRight;
|
||||
const h = rect.height - padTop - padBottom;
|
||||
const isParam = currentMode === 'parametric';
|
||||
const dbCenter = isParam ? 0 : 75;
|
||||
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
||||
const dbMin = dbCenter - dbHalf;
|
||||
const dbMax = dbCenter + dbHalf;
|
||||
|
||||
const freq = xToFreq(coords.x - padLeft, w);
|
||||
bands[touchNodeIdx].freq = Math.max(20, Math.min(20000, freq));
|
||||
if (isParam) {
|
||||
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
bands[touchNodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
||||
} else {
|
||||
const corrGain = interpolate(bands[touchNodeIdx].freq, autoeqCorrectedCurve || []);
|
||||
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
const gainDelta = newDb - corrGain;
|
||||
bands[touchNodeIdx].gain = Math.max(
|
||||
-30,
|
||||
Math.min(30, bands[touchNodeIdx].gain + gainDelta * 0.3)
|
||||
);
|
||||
}
|
||||
draggedNode = touchNodeIdx;
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
renderBandControls(bands);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
autoeqCanvas.addEventListener(
|
||||
// Document-level touchmove so dragging continues outside canvas
|
||||
document.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
if (draggedNode === null) return;
|
||||
const tBands = getActiveBands();
|
||||
if (draggedNode !== null && tBands) {
|
||||
const touch = e.touches[0];
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
||||
const padLeft = 40,
|
||||
padRight = 10,
|
||||
padTop = 10,
|
||||
padBottom = 30;
|
||||
const w = rect.width - padLeft - padRight;
|
||||
const h = rect.height - padTop - padBottom;
|
||||
if (!tBands) return;
|
||||
|
||||
const freq = xToFreq(coords.x - padLeft, w);
|
||||
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
|
||||
const touch = e.touches[0];
|
||||
const rect = autoeqCanvas.getBoundingClientRect();
|
||||
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
||||
const padLeft = 40,
|
||||
padRight = 10,
|
||||
padTop = 10,
|
||||
padBottom = 30;
|
||||
const w = rect.width - padLeft - padRight;
|
||||
const h = rect.height - padTop - padBottom;
|
||||
|
||||
if (currentMode === 'parametric') {
|
||||
const newGain = yToDb(coords.y - padTop, h, -graphDbHalfParametric, graphDbHalfParametric);
|
||||
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
||||
}
|
||||
const isParam = currentMode === 'parametric';
|
||||
const dbCenter = isParam ? 0 : 75;
|
||||
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
||||
const dbMin = dbCenter - dbHalf;
|
||||
const dbMax = dbCenter + dbHalf;
|
||||
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(tBands);
|
||||
if (!graphAnimFrame) {
|
||||
graphAnimFrame = requestAnimationFrame(() => {
|
||||
drawAutoEQGraph();
|
||||
renderBandControls(tBands);
|
||||
graphAnimFrame = null;
|
||||
});
|
||||
}
|
||||
e.preventDefault();
|
||||
const freq = xToFreq(coords.x - padLeft, w);
|
||||
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
|
||||
|
||||
if (isParam) {
|
||||
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
||||
} else {
|
||||
const corrGain = interpolate(tBands[draggedNode].freq, autoeqCorrectedCurve || []);
|
||||
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
||||
const gainDelta = newDb - corrGain;
|
||||
tBands[draggedNode].gain = Math.max(-30, Math.min(30, tBands[draggedNode].gain + gainDelta * 0.3));
|
||||
}
|
||||
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(tBands);
|
||||
if (!graphAnimFrame) {
|
||||
graphAnimFrame = requestAnimationFrame(() => {
|
||||
drawAutoEQGraph();
|
||||
renderBandControls(tBands);
|
||||
graphAnimFrame = null;
|
||||
});
|
||||
}
|
||||
e.preventDefault();
|
||||
},
|
||||
{ passive: false }
|
||||
{ passive: false, signal: graphSignal }
|
||||
);
|
||||
|
||||
autoeqCanvas.addEventListener('touchend', () => {
|
||||
draggedNode = null;
|
||||
touchNodeIdx = -1;
|
||||
});
|
||||
document.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
if (draggedNode !== null) {
|
||||
draggedNode = null;
|
||||
touchNodeIdx = -1;
|
||||
}
|
||||
},
|
||||
{ signal: graphSignal }
|
||||
);
|
||||
|
||||
// Resize observer for graph
|
||||
if (autoeqGraphWrapper) {
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawAutoEQGraph();
|
||||
_graphResizeObserver = new ResizeObserver(() => {
|
||||
scheduleDrawAutoEQGraph();
|
||||
});
|
||||
ro.observe(autoeqGraphWrapper);
|
||||
_graphResizeObserver.observe(autoeqGraphWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2166,7 +2461,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`;
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
scheduleDrawAutoEQGraph();
|
||||
});
|
||||
|
||||
gainSlider.addEventListener('input', () => {
|
||||
|
|
@ -2176,7 +2471,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`;
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
scheduleDrawAutoEQGraph();
|
||||
});
|
||||
|
||||
qSlider.addEventListener('input', () => {
|
||||
|
|
@ -2186,7 +2481,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
qVal.textContent = bands[i].q.toFixed(2);
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
scheduleDrawAutoEQGraph();
|
||||
});
|
||||
|
||||
const typeSelect = control.querySelector('.autoeq-type-select');
|
||||
|
|
@ -2196,7 +2491,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
bands[i].type = typeSelect.value;
|
||||
computeCorrectedCurve();
|
||||
applyBandsToAudio(bands);
|
||||
drawAutoEQGraph();
|
||||
scheduleDrawAutoEQGraph();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -2257,6 +2552,22 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
// Database section collapse
|
||||
const autoeqDatabaseToggle = document.getElementById('autoeq-database-toggle');
|
||||
const autoeqDatabaseCollapse = document.getElementById('autoeq-database-collapse');
|
||||
const autoeqDatabaseBody = document.getElementById('autoeq-database-body');
|
||||
if (autoeqDatabaseToggle) {
|
||||
autoeqDatabaseToggle.addEventListener('click', () => {
|
||||
if (autoeqDatabaseCollapse) autoeqDatabaseCollapse.classList.toggle('collapsed');
|
||||
if (autoeqDatabaseBody)
|
||||
autoeqDatabaseBody.style.display = autoeqDatabaseBody.style.display === 'none' ? '' : 'none';
|
||||
if (autoeqDatabaseCollapse) {
|
||||
const isExpanded = !autoeqDatabaseCollapse.classList.contains('collapsed');
|
||||
autoeqDatabaseCollapse.setAttribute('aria-expanded', String(isExpanded));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Set Status Message
|
||||
// ========================================
|
||||
|
|
@ -3113,13 +3424,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
const presetRow = document.getElementById('autoeq-preset-row');
|
||||
const parametricProfiles = document.getElementById('autoeq-parametric-profiles');
|
||||
const speakerSavedSection = document.getElementById('speaker-saved-section');
|
||||
const legacySection = document.getElementById('graphic-eq-section');
|
||||
|
||||
// Reset interactive state on switch
|
||||
draggedNode = null;
|
||||
hoveredNode = null;
|
||||
|
||||
// Graph always visible in all modes
|
||||
if (graphSection) graphSection.style.display = '';
|
||||
// Graph visible in all modes except legacy
|
||||
if (graphSection) graphSection.style.display = mode === 'legacy' ? 'none' : '';
|
||||
// Only show shared AutoEq button in AutoEQ mode
|
||||
if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none';
|
||||
|
||||
|
|
@ -3132,6 +3444,20 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
if (parametricProfiles) parametricProfiles.style.display = 'none';
|
||||
if (speakerSection) speakerSection.style.display = 'none';
|
||||
if (speakerSavedSection) speakerSavedSection.style.display = 'none';
|
||||
if (legacySection) legacySection.style.display = 'none';
|
||||
|
||||
if (mode === 'legacy') {
|
||||
if (legacySection) legacySection.style.display = '';
|
||||
// Disable parametric EQ entirely - only graphic EQ active to save resources
|
||||
audioContextManager.isEQEnabled = false;
|
||||
audioContextManager.toggleGraphicEQ(equalizerSettings.isEnabled());
|
||||
equalizerSettings.setGraphicEqEnabled(true);
|
||||
} else {
|
||||
// Disable graphic EQ entirely - only parametric EQ active to save resources
|
||||
audioContextManager.isEQEnabled = equalizerSettings.isEnabled();
|
||||
audioContextManager.toggleGraphicEQ(false);
|
||||
equalizerSettings.setGraphicEqEnabled(false);
|
||||
}
|
||||
|
||||
if (mode === 'autoeq') {
|
||||
if (controlsSection) controlsSection.style.display = '';
|
||||
|
|
@ -4400,7 +4726,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
equalizerSettings.setEnabled(enabled);
|
||||
updateEQContainerVisibility(enabled);
|
||||
|
||||
audioContextManager.toggleEQ(enabled);
|
||||
if (currentMode === 'legacy') {
|
||||
// Legacy mode uses graphic EQ chain
|
||||
audioContextManager.isEQEnabled = false;
|
||||
audioContextManager.toggleGraphicEQ(enabled);
|
||||
} else {
|
||||
// AutoEQ/Parametric/Speaker modes use parametric EQ chain
|
||||
audioContextManager.toggleEQ(enabled);
|
||||
audioContextManager.toggleGraphicEQ(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4515,7 +4849,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
// Restore EQ mode on startup
|
||||
const savedEQMode = localStorage.getItem(EQ_MODE_KEY);
|
||||
if (savedEQMode && ['autoeq', 'parametric', 'speaker'].includes(savedEQMode)) {
|
||||
if (savedEQMode && ['autoeq', 'parametric', 'speaker', 'legacy'].includes(savedEQMode)) {
|
||||
setEQMode(savedEQMode);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ export class SidePanelManager {
|
|||
}
|
||||
}
|
||||
|
||||
emitChange() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('side-panel-changed', {
|
||||
detail: {
|
||||
active: this.panel.classList.contains('active'),
|
||||
view: this.currentView,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
initResizer() {
|
||||
this.resizerElement.addEventListener('mousedown', this.startResize.bind(this));
|
||||
|
||||
|
|
@ -86,6 +97,7 @@ export class SidePanelManager {
|
|||
if (renderContentCallback) renderContentCallback(this.contentElement);
|
||||
|
||||
this.panel.classList.add('active');
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
close() {
|
||||
|
|
@ -105,6 +117,7 @@ export class SidePanelManager {
|
|||
|
||||
this.panel.classList.remove('active');
|
||||
this.currentView = null;
|
||||
this.emitChange();
|
||||
// Optionally clear content after transition
|
||||
setTimeout(() => {
|
||||
if (!this.panel.classList.contains('active')) {
|
||||
|
|
|
|||
|
|
@ -1697,6 +1697,71 @@ export const equalizerSettings = {
|
|||
clearLastHeadphone() {
|
||||
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||
},
|
||||
|
||||
// --- Graphic EQ (16-band) separate storage ---
|
||||
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
|
||||
GEQ_GAINS_KEY: 'graphic-eq-gains',
|
||||
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
|
||||
|
||||
isGraphicEqEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.GEQ_ENABLED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setGraphicEqEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(this.GEQ_ENABLED_KEY, String(!!enabled));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
getGraphicEqGains() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length === 16) {
|
||||
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return new Array(16).fill(0);
|
||||
},
|
||||
|
||||
setGraphicEqGains(gains) {
|
||||
try {
|
||||
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(gains));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
getGraphicEqPreamp() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.GEQ_PREAMP_KEY);
|
||||
if (val !== null) {
|
||||
const num = parseFloat(val);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
setGraphicEqPreamp(db) {
|
||||
try {
|
||||
localStorage.setItem(this.GEQ_PREAMP_KEY, String(db));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const monoAudioSettings = {
|
||||
|
|
|
|||
377
js/ui.js
|
|
@ -15,7 +15,7 @@ import {
|
|||
escapeHtml,
|
||||
getShareUrl,
|
||||
} from './utils.js';
|
||||
import { openLyricsPanel } from './lyrics.js';
|
||||
import { openLyricsPanel, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
|
||||
import {
|
||||
recentActivityManager,
|
||||
backgroundSettings,
|
||||
|
|
@ -27,9 +27,6 @@ import {
|
|||
contentBlockingSettings,
|
||||
settingsUiState,
|
||||
fullscreenCoverNoRoundSettings,
|
||||
fullscreenCoverVanillaTiltSettings,
|
||||
fullscreenCoverTiltDistanceSettings,
|
||||
fullscreenCoverTiltSpeedSettings,
|
||||
} from './storage.js';
|
||||
import { db } from './db.js';
|
||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||
|
|
@ -61,6 +58,8 @@ import {
|
|||
SVG_HEART,
|
||||
SVG_VOLUME,
|
||||
SVG_MUTE,
|
||||
SVG_EYE,
|
||||
SVG_EYE_OFF,
|
||||
SVG_HEART_FILLED,
|
||||
SVG_CLOSE,
|
||||
SVG_SORT,
|
||||
|
|
@ -89,6 +88,11 @@ import {
|
|||
SVG_CHECKBOX,
|
||||
} from './icons.js';
|
||||
|
||||
const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => {
|
||||
if (!button) return;
|
||||
button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24);
|
||||
};
|
||||
|
||||
function sortTracks(tracks, sortType) {
|
||||
if (sortType === 'custom') return [...tracks];
|
||||
const sorted = [...tracks];
|
||||
|
|
@ -177,20 +181,8 @@ export class UIRenderer {
|
|||
} else {
|
||||
overlay.classList.remove('fullscreen-cover-no-round');
|
||||
}
|
||||
if (coverImage) {
|
||||
if (fullscreenCoverVanillaTiltSettings.isEnabled() && window.VanillaTilt) {
|
||||
if (coverImage.vanillaTilt) {
|
||||
coverImage.vanillaTilt.destroy();
|
||||
}
|
||||
window.VanillaTilt.init(coverImage, {
|
||||
max: fullscreenCoverTiltDistanceSettings.getValue(),
|
||||
speed: fullscreenCoverTiltSpeedSettings.getValue(),
|
||||
glare: true,
|
||||
'max-glare': 0.3,
|
||||
});
|
||||
} else if (coverImage.vanillaTilt) {
|
||||
coverImage.vanillaTilt.destroy();
|
||||
}
|
||||
if (coverImage?.vanillaTilt) {
|
||||
coverImage.vanillaTilt.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1168,6 +1160,23 @@ export class UIRenderer {
|
|||
root.style.removeProperty('--track-hover-bg');
|
||||
}
|
||||
|
||||
getFullscreenQualityBadgeHTML(track) {
|
||||
const nowPlayingTitle = document.querySelector('.now-playing-bar .title');
|
||||
if (nowPlayingTitle && this.player?.currentTrack?.id === track?.id) {
|
||||
const badges = Array.from(nowPlayingTitle.querySelectorAll('.shaka-quality-badge, .quality-badge'));
|
||||
const liveBadge = badges.find((badge) => getComputedStyle(badge).display !== 'none') || badges[0];
|
||||
if (liveBadge) {
|
||||
const badgeClone = liveBadge.cloneNode(true);
|
||||
if (badgeClone instanceof HTMLElement) {
|
||||
badgeClone.style.removeProperty('display');
|
||||
}
|
||||
return badgeClone.outerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
return createQualityBadgeHTML(track);
|
||||
}
|
||||
|
||||
async updateFullscreenMetadata(track, nextTrack) {
|
||||
if (!track) return;
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
|
|
@ -1265,7 +1274,7 @@ export class UIRenderer {
|
|||
await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
||||
}
|
||||
|
||||
const qualityBadge = createQualityBadgeHTML(track);
|
||||
const qualityBadge = this.getFullscreenQualityBadgeHTML(track);
|
||||
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
|
||||
artist.textContent = getTrackArtists(track);
|
||||
|
||||
|
|
@ -1279,11 +1288,14 @@ export class UIRenderer {
|
|||
|
||||
async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
|
||||
if (!track) return;
|
||||
this.fullscreenVisualizerSuppressed = true;
|
||||
if (window.location.hash !== '#fullscreen') {
|
||||
window.history.pushState({ fullscreen: true }, '', '#fullscreen');
|
||||
}
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
||||
const lyricsPane = document.getElementById('fullscreen-lyrics-pane');
|
||||
const lyricsContent = document.getElementById('fullscreen-lyrics-content');
|
||||
const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn');
|
||||
|
||||
await this.updateFullscreenMetadata(track, nextTrack);
|
||||
|
|
@ -1296,27 +1308,36 @@ export class UIRenderer {
|
|||
nextTrackEl.classList.remove('animate-in');
|
||||
}
|
||||
|
||||
if (lyricsManager && activeElement) {
|
||||
lyricsToggleBtn.style.display = 'flex';
|
||||
lyricsToggleBtn.classList.remove('active');
|
||||
|
||||
const toggleLyrics = () => {
|
||||
openLyricsPanel(track, activeElement, lyricsManager);
|
||||
lyricsToggleBtn.classList.toggle('active');
|
||||
};
|
||||
|
||||
const newToggleBtn = lyricsToggleBtn.cloneNode(true);
|
||||
lyricsToggleBtn.parentNode.replaceChild(newToggleBtn, lyricsToggleBtn);
|
||||
newToggleBtn.addEventListener('click', toggleLyrics);
|
||||
const canRenderLyrics = Boolean(
|
||||
lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'
|
||||
);
|
||||
if (canRenderLyrics) {
|
||||
lyricsToggleBtn.style.display = 'none';
|
||||
overlay.classList.remove('lyrics-unavailable');
|
||||
clearFullscreenLyricsSync(lyricsContent);
|
||||
await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent);
|
||||
} else {
|
||||
lyricsToggleBtn.style.display = 'none';
|
||||
overlay.classList.add('lyrics-unavailable');
|
||||
if (lyricsContent) {
|
||||
clearFullscreenLyricsSync(lyricsContent);
|
||||
lyricsContent.innerHTML =
|
||||
'<div class="fullscreen-lyrics-empty">Lyrics are not available for this track.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
const playerBar = document.querySelector('.now-playing-bar');
|
||||
if (playerBar) playerBar.style.display = 'none';
|
||||
if (sidePanelManager.isActive('lyrics') || sidePanelManager.isActive('queue')) {
|
||||
sidePanelManager.close();
|
||||
}
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent instanceof HTMLElement) {
|
||||
this.fullscreenMainContentOverflow = mainContent.style.overflowY;
|
||||
mainContent.style.overflowY = 'hidden';
|
||||
}
|
||||
|
||||
this.setupFullscreenControls();
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
if (fullscreenCoverNoRoundSettings.isEnabled()) {
|
||||
|
|
@ -1326,75 +1347,45 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
const coverImage = document.getElementById('fullscreen-cover-image');
|
||||
if (fullscreenCoverVanillaTiltSettings.isEnabled() && coverImage && window.VanillaTilt) {
|
||||
window.VanillaTilt.init(coverImage, {
|
||||
max: fullscreenCoverTiltDistanceSettings.getValue(),
|
||||
speed: fullscreenCoverTiltSpeedSettings.getValue(),
|
||||
glare: true,
|
||||
'max-glare': 0.3,
|
||||
});
|
||||
if (coverImage?.vanillaTilt) {
|
||||
coverImage.vanillaTilt.destroy();
|
||||
}
|
||||
|
||||
const startVisualizer = async () => {
|
||||
if (!visualizerSettings.isEnabled()) {
|
||||
if (this.visualizer) this.visualizer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.visualizer && activeElement) {
|
||||
const canvas = document.getElementById('visualizer-canvas');
|
||||
if (canvas) {
|
||||
this.visualizer = new Visualizer(canvas, activeElement);
|
||||
await this.visualizer.initPresets();
|
||||
}
|
||||
}
|
||||
if (this.visualizer) {
|
||||
await this.visualizer.start();
|
||||
}
|
||||
|
||||
// Add visualizer-active class for enhanced drop shadow
|
||||
overlay.classList.add('visualizer-active');
|
||||
};
|
||||
|
||||
// Setup UI toggle button
|
||||
this.setupUIToggleButton(overlay);
|
||||
|
||||
if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') {
|
||||
await startVisualizer();
|
||||
} else {
|
||||
const modal = document.getElementById('epilepsy-warning-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
|
||||
const acceptBtn = document.getElementById('epilepsy-accept-btn');
|
||||
const cancelBtn = document.getElementById('epilepsy-cancel-btn');
|
||||
|
||||
acceptBtn.onclick = async () => {
|
||||
modal.classList.remove('active');
|
||||
localStorage.setItem('epilepsy-warning-dismissed', 'true');
|
||||
await startVisualizer();
|
||||
};
|
||||
cancelBtn.onclick = () => {
|
||||
modal.classList.remove('active');
|
||||
this.closeFullscreenCover();
|
||||
};
|
||||
} else {
|
||||
await startVisualizer();
|
||||
}
|
||||
}
|
||||
this.setupControlsAutoHide(overlay);
|
||||
this.setupFullscreenSidePanelSync(overlay);
|
||||
await this.refreshFullscreenVisualizerState(activeElement);
|
||||
}
|
||||
|
||||
closeFullscreenCover() {
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
const coverImage = document.getElementById('fullscreen-cover-image');
|
||||
const lyricsContent = document.getElementById('fullscreen-lyrics-content');
|
||||
if (coverImage && coverImage.vanillaTilt) {
|
||||
coverImage.vanillaTilt.destroy();
|
||||
}
|
||||
if (lyricsContent) {
|
||||
clearFullscreenLyricsSync(lyricsContent);
|
||||
lyricsContent.innerHTML = '<div class="fullscreen-lyrics-empty">Lyrics appear here.</div>';
|
||||
}
|
||||
overlay.style.display = 'none';
|
||||
overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round');
|
||||
overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round', 'fullscreen-paused');
|
||||
|
||||
const playerBar = document.querySelector('.now-playing-bar');
|
||||
if (playerBar) playerBar.style.removeProperty('display');
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent instanceof HTMLElement) {
|
||||
if (
|
||||
typeof this.fullscreenMainContentOverflow === 'string' &&
|
||||
this.fullscreenMainContentOverflow.length > 0
|
||||
) {
|
||||
mainContent.style.overflowY = this.fullscreenMainContentOverflow;
|
||||
} else {
|
||||
mainContent.style.removeProperty('overflow-y');
|
||||
}
|
||||
this.fullscreenMainContentOverflow = null;
|
||||
}
|
||||
|
||||
if (this.player?.currentTrack?.type === 'video') {
|
||||
const coverContainer = document.querySelector('.now-playing-bar .track-info');
|
||||
|
|
@ -1426,21 +1417,134 @@ export class UIRenderer {
|
|||
if (this.visualizer) {
|
||||
this.visualizer.stop();
|
||||
}
|
||||
this.fullscreenVisualizerSuppressed = false;
|
||||
|
||||
// Clear UI toggle button timers
|
||||
if (this.uiToggleMouseTimer) {
|
||||
clearTimeout(this.uiToggleMouseTimer);
|
||||
this.uiToggleMouseTimer = null;
|
||||
}
|
||||
|
||||
if (this.controlsIdleCleanup) {
|
||||
this.controlsIdleCleanup();
|
||||
this.controlsIdleCleanup = null;
|
||||
}
|
||||
|
||||
if (this.fullscreenSidePanelSyncCleanup) {
|
||||
this.fullscreenSidePanelSyncCleanup();
|
||||
this.fullscreenSidePanelSyncCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
async startFullscreenVisualizer(activeElement, overlay) {
|
||||
if (!activeElement) return;
|
||||
|
||||
if (!this.visualizer) {
|
||||
const canvas = document.getElementById('visualizer-canvas');
|
||||
if (canvas) {
|
||||
this.visualizer = new Visualizer(canvas, activeElement);
|
||||
await this.visualizer.initPresets();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visualizer) {
|
||||
await this.visualizer.start();
|
||||
overlay.classList.add('visualizer-active');
|
||||
}
|
||||
}
|
||||
|
||||
async ensureVisualizerPermission(activeElement, overlay, { closeOnCancel = false } = {}) {
|
||||
if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') {
|
||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
||||
return true;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('epilepsy-warning-modal');
|
||||
if (!modal) {
|
||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
||||
return true;
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
modal.classList.add('active');
|
||||
|
||||
const acceptBtn = document.getElementById('epilepsy-accept-btn');
|
||||
const cancelBtn = document.getElementById('epilepsy-cancel-btn');
|
||||
|
||||
acceptBtn.onclick = async () => {
|
||||
modal.classList.remove('active');
|
||||
localStorage.setItem('epilepsy-warning-dismissed', 'true');
|
||||
await this.startFullscreenVisualizer(activeElement, overlay);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => {
|
||||
modal.classList.remove('active');
|
||||
if (closeOnCancel) {
|
||||
this.closeFullscreenCover();
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async refreshFullscreenVisualizerState(activeElement, { closeOnCancel = false } = {}) {
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
const visualizerBtn = document.getElementById('fs-visualizer-btn');
|
||||
const toggleBtn = document.getElementById('toggle-ui-btn');
|
||||
const isVideoTrack = this.player?.currentTrack?.type === 'video';
|
||||
const enabled = visualizerSettings.isEnabled() && !isVideoTrack && !this.fullscreenVisualizerSuppressed;
|
||||
|
||||
if (!overlay) return;
|
||||
|
||||
if (visualizerBtn) {
|
||||
visualizerBtn.style.display = isVideoTrack ? 'none' : 'flex';
|
||||
visualizerBtn.classList.toggle('active', enabled);
|
||||
visualizerBtn.title = enabled ? 'Disable Visualizer' : 'Use Visualizer';
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
overlay.classList.remove('visualizer-active');
|
||||
overlay.classList.remove('ui-hidden');
|
||||
if (this.visualizer) {
|
||||
this.visualizer.stop();
|
||||
}
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('active', 'visible');
|
||||
toggleBtn.title = 'Hide UI';
|
||||
setFullscreenUIToggleIcon(toggleBtn, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await this.ensureVisualizerPermission(activeElement, overlay, { closeOnCancel });
|
||||
if (!allowed) {
|
||||
this.fullscreenVisualizerSuppressed = true;
|
||||
overlay.classList.remove('visualizer-active');
|
||||
if (this.visualizer) {
|
||||
this.visualizer.stop();
|
||||
}
|
||||
if (visualizerBtn) {
|
||||
visualizerBtn.classList.remove('active');
|
||||
visualizerBtn.title = 'Use Visualizer';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupUIToggleButton(overlay) {
|
||||
const toggleBtn = document.getElementById('toggle-ui-btn');
|
||||
if (!toggleBtn) return;
|
||||
|
||||
const updateToggleButtonIcon = () => {
|
||||
const visualizerOnlyMode =
|
||||
overlay.classList.contains('ui-hidden') && overlay.classList.contains('visualizer-active');
|
||||
setFullscreenUIToggleIcon(toggleBtn, visualizerOnlyMode);
|
||||
};
|
||||
|
||||
let isUIHidden = overlay.classList.contains('ui-hidden');
|
||||
toggleBtn.classList.toggle('active', isUIHidden);
|
||||
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
|
||||
updateToggleButtonIcon();
|
||||
|
||||
// Show button
|
||||
const showButton = () => {
|
||||
|
|
@ -1459,37 +1563,55 @@ export class UIRenderer {
|
|||
showButton();
|
||||
}
|
||||
|
||||
const toggleUI = (e) => {
|
||||
const toggleUI = async (e) => {
|
||||
if (e) e.stopPropagation();
|
||||
if (!overlay.classList.contains('visualizer-active')) {
|
||||
const isVideoTrack = this.player?.currentTrack?.type === 'video';
|
||||
if (isVideoTrack) {
|
||||
overlay.classList.remove('ui-hidden');
|
||||
isUIHidden = false;
|
||||
toggleBtn.classList.remove('active');
|
||||
toggleBtn.title = 'Hide UI';
|
||||
updateToggleButtonIcon();
|
||||
showButton();
|
||||
return;
|
||||
}
|
||||
|
||||
this.fullscreenVisualizerSuppressed = false;
|
||||
visualizerSettings.setEnabled(true);
|
||||
await this.refreshFullscreenVisualizerState(this.player?.activeElement);
|
||||
|
||||
if (!overlay.classList.contains('visualizer-active')) {
|
||||
overlay.classList.remove('ui-hidden');
|
||||
isUIHidden = false;
|
||||
toggleBtn.classList.remove('active');
|
||||
toggleBtn.title = 'Hide UI';
|
||||
updateToggleButtonIcon();
|
||||
showButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
isUIHidden = !isUIHidden;
|
||||
overlay.classList.toggle('ui-hidden', isUIHidden);
|
||||
toggleBtn.classList.toggle('active', isUIHidden);
|
||||
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
|
||||
updateToggleButtonIcon();
|
||||
|
||||
if (isUIHidden) {
|
||||
hideButton();
|
||||
showButton();
|
||||
} else {
|
||||
showButton();
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse move handler
|
||||
const handleMouseMove = (e) => {
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
|
||||
|
||||
if (isUIHidden) {
|
||||
if (overlay.classList.contains('is-video-mode')) {
|
||||
if (isNearTopRight) {
|
||||
showButton();
|
||||
} else {
|
||||
hideButton();
|
||||
}
|
||||
} else if (isNearTopRight) {
|
||||
showButton();
|
||||
} else {
|
||||
hideButton();
|
||||
}
|
||||
if (!isUIHidden) return;
|
||||
const btnRect = toggleBtn.getBoundingClientRect();
|
||||
const nearBtn = e.clientY < 100 && Math.abs(e.clientX - (btnRect.left + btnRect.width / 2)) < 150;
|
||||
if (nearBtn) {
|
||||
showButton();
|
||||
} else {
|
||||
hideButton();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1509,12 +1631,41 @@ export class UIRenderer {
|
|||
};
|
||||
}
|
||||
|
||||
setupControlsAutoHide(overlay) {
|
||||
if (this.controlsIdleCleanup) this.controlsIdleCleanup();
|
||||
overlay.classList.remove('controls-idle');
|
||||
|
||||
this.controlsIdleCleanup = () => {
|
||||
overlay.classList.remove('controls-idle');
|
||||
};
|
||||
}
|
||||
|
||||
setupFullscreenSidePanelSync(overlay) {
|
||||
if (this.fullscreenSidePanelSyncCleanup) {
|
||||
this.fullscreenSidePanelSyncCleanup();
|
||||
}
|
||||
|
||||
const syncState = () => {
|
||||
overlay.classList.toggle('queue-panel-active', sidePanelManager.isActive('queue'));
|
||||
};
|
||||
|
||||
const handleChange = () => syncState();
|
||||
window.addEventListener('side-panel-changed', handleChange);
|
||||
syncState();
|
||||
|
||||
this.fullscreenSidePanelSyncCleanup = () => {
|
||||
window.removeEventListener('side-panel-changed', handleChange);
|
||||
overlay.classList.remove('queue-panel-active');
|
||||
};
|
||||
}
|
||||
|
||||
setupFullscreenControls() {
|
||||
const playBtn = document.getElementById('fs-play-pause-btn');
|
||||
const prevBtn = document.getElementById('fs-prev-btn');
|
||||
const nextBtn = document.getElementById('fs-next-btn');
|
||||
const shuffleBtn = document.getElementById('fs-shuffle-btn');
|
||||
const repeatBtn = document.getElementById('fs-repeat-btn');
|
||||
const visualizerBtn = document.getElementById('fs-visualizer-btn');
|
||||
const progressBar = document.getElementById('fs-progress-bar');
|
||||
const progressFill = document.getElementById('fs-progress-fill');
|
||||
const currentTimeEl = document.getElementById('fs-current-time');
|
||||
|
|
@ -1575,6 +1726,22 @@ export class UIRenderer {
|
|||
}
|
||||
};
|
||||
|
||||
if (visualizerBtn) {
|
||||
visualizerBtn.onclick = async () => {
|
||||
if (this.fullscreenVisualizerSuppressed) {
|
||||
this.fullscreenVisualizerSuppressed = false;
|
||||
visualizerSettings.setEnabled(true);
|
||||
} else if (visualizerSettings.isEnabled()) {
|
||||
visualizerSettings.setEnabled(false);
|
||||
this.fullscreenVisualizerSuppressed = false;
|
||||
} else {
|
||||
this.fullscreenVisualizerSuppressed = false;
|
||||
visualizerSettings.setEnabled(true);
|
||||
}
|
||||
await this.refreshFullscreenVisualizerState(this.player.activeElement);
|
||||
};
|
||||
}
|
||||
|
||||
// Progress bar with drag support
|
||||
let isFsSeeking = false;
|
||||
let wasFsPlaying = false;
|
||||
|
|
@ -3437,8 +3604,8 @@ export class UIRenderer {
|
|||
);
|
||||
const data = await response.json();
|
||||
|
||||
rateCriticsEl.innerHTML = `<a href="${data.url}" style="color: var(--muted-foreground);">Critic Score: <span style="text-decoration: underline;">${data.critic.score}</span>, Based on ${data.critic.count} reviews</a>`;
|
||||
rateUsersEl.innerHTML = `<a href="${data.url}" style="color: var(--muted-foreground);">User Score: <span style="text-decoration: underline;">${data.user.score}</span>, Based on ${data.user.count} reviews</a>`;
|
||||
rateCriticsEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">Critic Score: <span style="text-decoration: underline;">${data.critic.score}</span>, Based on ${data.critic.count} reviews</a>`;
|
||||
rateUsersEl.innerHTML = `<a href="${data.url}" target="_blank" style="color: var(--muted-foreground);">User Score: <span style="text-decoration: underline;">${data.user.score}</span>, Based on ${data.user.count} reviews</a>`;
|
||||
} catch (e) {
|
||||
rateCriticsEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch Critic Score</a>`;
|
||||
rateUsersEl.innerHTML = `<a style="color: var(--muted-foreground);">Unable to Fetch User Score</a>`;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ export class Visualizer {
|
|||
// ---- CACHED STATE ----
|
||||
this._lastPrimaryColor = '';
|
||||
this._resizeBound = () => this.resize();
|
||||
this._backgroundPaused = false;
|
||||
|
||||
// Pause animation loop when the app is backgrounded so the analyser's
|
||||
// FFT reads don't compete with the EQ biquad filter chain for audio
|
||||
// thread time — the main cause of audio skipping with AutoEQ in background.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden' && this.isActive) {
|
||||
this._backgroundPaused = true;
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
} else if (document.visibilityState === 'visible' && this._backgroundPaused) {
|
||||
this._backgroundPaused = false;
|
||||
if (this.isActive && !this.animationId) {
|
||||
this.animate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
274
public/editors-picks-old/2026-4-5.json
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
[
|
||||
{
|
||||
"type": "album",
|
||||
"id": 324660713,
|
||||
"title": "JOECHILLWORLD",
|
||||
"artist": {
|
||||
"id": 40978758,
|
||||
"name": "Devon Hendryx"
|
||||
},
|
||||
"releaseDate": "2010-07-10",
|
||||
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 15427733,
|
||||
"title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp",
|
||||
"artist": {
|
||||
"id": 4611745,
|
||||
"name": "Spaceghostpurrp"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "c78b7543-1cd8-4921-9155-e81d421353a0",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 464178301,
|
||||
"title": "Never Forget",
|
||||
"artist": {
|
||||
"id": 5516508,
|
||||
"name": "Chris Travis"
|
||||
},
|
||||
"releaseDate": "2014-05-14",
|
||||
"cover": "4ab11f0d-0768-4cce-8de5-1894134d5994",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 75115890,
|
||||
"title": "Blood Shore Season 2",
|
||||
"artist": {
|
||||
"id": 6332342,
|
||||
"name": "Xavier Wulf"
|
||||
},
|
||||
"releaseDate": "2014-10-30",
|
||||
"cover": "517303e5-d541-4704-b552-026427e05fcb",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 410197513,
|
||||
"title": "THE PEAK",
|
||||
"artist": {
|
||||
"id": 33481052,
|
||||
"name": "smokedope2016"
|
||||
},
|
||||
"releaseDate": "2025-01-17",
|
||||
"cover": "ea18084d-36ec-4cea-98a7-fe4684246986",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 418729278,
|
||||
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
|
||||
"artist": {
|
||||
"id": 7958797,
|
||||
"name": "JPEGMAFIA"
|
||||
},
|
||||
"releaseDate": "2025-02-03",
|
||||
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 504004321,
|
||||
"title": "Half Blood (BloodLuxe)",
|
||||
"artist": {
|
||||
"id": 50799233,
|
||||
"name": "slayr"
|
||||
},
|
||||
"releaseDate": "2025-11-05",
|
||||
"cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 510893864,
|
||||
"title": "BULLY",
|
||||
"artist": {
|
||||
"id": 25022,
|
||||
"name": "Kanye West"
|
||||
},
|
||||
"releaseDate": "2026-03-28",
|
||||
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 325723583,
|
||||
"title": "Replica",
|
||||
"artist": {
|
||||
"id": 3715530,
|
||||
"name": "Oneohtrix Point Never"
|
||||
},
|
||||
"releaseDate": "2011-11-05",
|
||||
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 336178142,
|
||||
"title": "Pirate This Album",
|
||||
"artist": {
|
||||
"id": 8622751,
|
||||
"name": "Shamana"
|
||||
},
|
||||
"releaseDate": "2023-12-25",
|
||||
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 106369871,
|
||||
"title": "Organic Thoughts from the Synthetic Mind",
|
||||
"artist": {
|
||||
"id": 6436013,
|
||||
"name": "Shinjuku Mad"
|
||||
},
|
||||
"releaseDate": "2009-07-01",
|
||||
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 423471869,
|
||||
"title": "pain",
|
||||
"artist": {
|
||||
"id": 44257324,
|
||||
"name": "bleood"
|
||||
},
|
||||
"releaseDate": "2025-03-11",
|
||||
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 250986538,
|
||||
"title": "Revolutionary, Vol. 1 (Bonus Edition)",
|
||||
"artist": {
|
||||
"id": 3604583,
|
||||
"name": "Immortal Technique"
|
||||
},
|
||||
"releaseDate": "2001-09-14",
|
||||
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 509761344,
|
||||
"title": "EMOTIONS",
|
||||
"artist": {
|
||||
"id": 49124576,
|
||||
"name": "Nine Vicious"
|
||||
},
|
||||
"releaseDate": "2026-04-03",
|
||||
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 15621057,
|
||||
"title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
|
||||
"artist": {
|
||||
"id": 3654061,
|
||||
"name": "Waka Flocka Flame"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 103897783,
|
||||
"title": "Freewave 3",
|
||||
"artist": {
|
||||
"id": 7923685,
|
||||
"name": "Lucki"
|
||||
},
|
||||
"releaseDate": "2019-02-15",
|
||||
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 151728406,
|
||||
"title": "Niagara",
|
||||
"artist": {
|
||||
"id": 7607680,
|
||||
"name": "redveil"
|
||||
},
|
||||
"releaseDate": "2020-08-25",
|
||||
"cover": "14690142-7fc8-4557-8a61-0721b7884822",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
[
|
||||
{
|
||||
"file": "2026-4-5.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-05"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-5.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-05"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-3.json",
|
||||
"label": "Spring 2026",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"name": "Devon Hendryx"
|
||||
},
|
||||
"releaseDate": "2010-07-10",
|
||||
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/25d45544-3e82-4184-b8c2-2c2c6f0f152a.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"name": "Spaceghostpurrp"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "c78b7543-1cd8-4921-9155-e81d421353a0",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/c78b7543-1cd8-4921-9155-e81d421353a0.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
"name": "Chris Travis"
|
||||
},
|
||||
"releaseDate": "2014-05-14",
|
||||
"cover": "4ab11f0d-0768-4cce-8de5-1894134d5994",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/4ab11f0d-0768-4cce-8de5-1894134d5994.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
"name": "Xavier Wulf"
|
||||
},
|
||||
"releaseDate": "2014-10-30",
|
||||
"cover": "517303e5-d541-4704-b552-026427e05fcb",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/517303e5-d541-4704-b552-026427e05fcb.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
"name": "smokedope2016"
|
||||
},
|
||||
"releaseDate": "2025-01-17",
|
||||
"cover": "ea18084d-36ec-4cea-98a7-fe4684246986",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/ea18084d-36ec-4cea-98a7-fe4684246986.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
"name": "JPEGMAFIA"
|
||||
},
|
||||
"releaseDate": "2025-02-03",
|
||||
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/9c84302b-2584-4c0a-9db7-e648542f459f.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"name": "slayr"
|
||||
},
|
||||
"releaseDate": "2025-11-05",
|
||||
"cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/2767cc63-7e92-4a48-aa4b-806a3ea7ec1c.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
"name": "Kanye West"
|
||||
},
|
||||
"releaseDate": "2026-03-28",
|
||||
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
"name": "Oneohtrix Point Never"
|
||||
},
|
||||
"releaseDate": "2011-11-05",
|
||||
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/95ceeae9-cac7-42dc-ae37-7c93c223f809.webp",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
"name": "Shamana"
|
||||
},
|
||||
"releaseDate": "2023-12-25",
|
||||
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/a8a647be-0331-4779-9a6e-31645a9abdab.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
"name": "Shinjuku Mad"
|
||||
},
|
||||
"releaseDate": "2009-07-01",
|
||||
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/3acc888e-35da-40a8-a4b7-7ffd00576cc9.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
"name": "bleood"
|
||||
},
|
||||
"releaseDate": "2025-03-11",
|
||||
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/711b23ba-c473-44e6-a2f0-010fefa9c5b8.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -200,7 +200,7 @@
|
|||
"name": "Immortal Technique"
|
||||
},
|
||||
"releaseDate": "2001-09-14",
|
||||
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/e510dd6d-dcdf-4272-9c68-f4580f2fbd14.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -216,7 +216,7 @@
|
|||
"name": "Nine Vicious"
|
||||
},
|
||||
"releaseDate": "2026-04-03",
|
||||
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/f29b18d3-b19f-45b1-968a-0ad360647130.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -232,7 +232,7 @@
|
|||
"name": "Waka Flocka Flame"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/3199b7de-5e3d-486c-acf1-870ff4c60572.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
@ -248,7 +248,23 @@
|
|||
"name": "Lucki"
|
||||
},
|
||||
"releaseDate": "2019-02-15",
|
||||
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 151728406,
|
||||
"title": "Niagara",
|
||||
"artist": {
|
||||
"id": 7607680,
|
||||
"name": "redveil"
|
||||
},
|
||||
"releaseDate": "2020-08-25",
|
||||
"cover": "https://monochrome.tf/editors-picks-images/14690142-7fc8-4557-8a61-0721b7884822.webp",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
|
|
|
|||
864
styles.css
|
|
@ -970,6 +970,7 @@ ul {
|
|||
display: grid;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
grid-template:
|
||||
'sidebar main' 1fr
|
||||
'player player' auto / 210px 1fr;
|
||||
|
|
@ -977,6 +978,7 @@ ul {
|
|||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
min-height: 0;
|
||||
background-color: var(--background);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
|
|
@ -1023,7 +1025,10 @@ ul {
|
|||
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-xl);
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
|
|
@ -3934,7 +3939,26 @@ input:checked + .slider::before {
|
|||
filter: var(--cover-filter);
|
||||
z-index: -1;
|
||||
background-image: var(--bg-image);
|
||||
transition: background-image var(--transition);
|
||||
transition:
|
||||
background-image var(--transition),
|
||||
filter 0.65s ease,
|
||||
opacity 0.65s ease;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 22%, rgb(var(--highlight-rgb) / 0.28), transparent 36%),
|
||||
radial-gradient(circle at 82% 18%, rgb(255 255 255 / 0.09), transparent 28%),
|
||||
linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--highlight-rgb) / 0.12) 100%);
|
||||
opacity: 0.36;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transition:
|
||||
opacity 0.65s ease,
|
||||
background 0.65s ease;
|
||||
}
|
||||
|
||||
#visualizer-container {
|
||||
|
|
@ -3944,7 +3968,13 @@ input:checked + .slider::before {
|
|||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
filter: blur(14px) saturate(0.84) brightness(0.8);
|
||||
transform: scale(1.04);
|
||||
opacity: 0.82;
|
||||
transition:
|
||||
opacity 0.65s ease,
|
||||
filter 0.65s ease,
|
||||
transform 0.65s ease;
|
||||
}
|
||||
|
||||
#visualizer-canvas {
|
||||
|
|
@ -3963,6 +3993,7 @@ input:checked + .slider::before {
|
|||
height: 100%;
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* UI Toggle Button for Visualizer Mode - Rightmost position */
|
||||
|
|
@ -4079,11 +4110,22 @@ input:checked + .slider::before {
|
|||
/* When UI is hidden, only toggle button stays visible at right edge (when .visible class is added) */
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay.ui-hidden #close-fullscreen-cover-btn {
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden::before,
|
||||
#fullscreen-cover-overlay.ui-hidden::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden #visualizer-container {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-main-view,
|
||||
#fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-controls,
|
||||
#fullscreen-cover-overlay:not(.ui-hidden) #fullscreen-next-track {
|
||||
|
|
@ -4092,6 +4134,60 @@ input:checked + .slider::before {
|
|||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* Auto-hide controls on idle */
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-track-info,
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-controls,
|
||||
#fullscreen-cover-overlay.controls-idle #fullscreen-next-track,
|
||||
#fullscreen-cover-overlay.controls-idle #toggle-ui-btn,
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-top-actions {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.6s ease,
|
||||
transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.controls-idle #fullscreen-cover-image {
|
||||
transform: translateY(4rem);
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay:not(.controls-idle) #fullscreen-cover-image {
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-controls {
|
||||
transform: translateY(1.5rem);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-track-info {
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.controls-idle #toggle-ui-btn,
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay.controls-idle .fullscreen-top-actions {
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-track-info,
|
||||
#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-controls,
|
||||
#fullscreen-cover-overlay:not(.controls-idle) #fullscreen-next-track,
|
||||
#fullscreen-cover-overlay:not(.controls-idle) #toggle-ui-btn,
|
||||
#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-top-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.controls-idle {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-image {
|
||||
max-width: 55vw;
|
||||
max-height: 45vh;
|
||||
|
|
@ -4946,7 +5042,7 @@ input:checked + .slider::before {
|
|||
|
||||
#download-notifications {
|
||||
position: fixed;
|
||||
bottom: 120px;
|
||||
bottom: calc(max(env(safe-area-inset-bottom), 0px) + 12px);
|
||||
right: 20px;
|
||||
z-index: 20000;
|
||||
max-width: 350px;
|
||||
|
|
@ -6774,7 +6870,7 @@ img[src=''] {
|
|||
}
|
||||
|
||||
#download-notifications {
|
||||
bottom: 10px;
|
||||
bottom: calc(max(env(safe-area-inset-bottom), 0px) + 10px);
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
|
|
@ -7721,6 +7817,148 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
16-Band Graphic Equalizer (Legacy EQ)
|
||||
======================================== */
|
||||
.graphic-eq-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.graphic-eq-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.graphic-eq-preset-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graphic-eq-preset-select {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.graphic-eq-bands {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-md) var(--spacing-sm);
|
||||
background: rgb(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius);
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.graphic-eq-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.graphic-eq-band-value {
|
||||
font-size: 0.65rem;
|
||||
color: var(--foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-height: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.graphic-eq-band-slider-wrap {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
width: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.graphic-eq-band-slider-wrap input[type='range'] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
accent-color: var(--foreground);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.graphic-eq-band-label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.graphic-eq-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.graphic-eq-preamp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.graphic-eq-preamp-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graphic-eq-preamp-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
accent-color: var(--highlight);
|
||||
}
|
||||
|
||||
.graphic-eq-preamp-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
min-width: 45px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.graphic-eq-bands {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.graphic-eq-band-slider-wrap {
|
||||
height: 130px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.graphic-eq-band-label {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.graphic-eq-band-value {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Precision AutoEQ - Redesigned Equalizer
|
||||
======================================== */
|
||||
|
|
@ -8341,6 +8579,19 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.autoeq-database-header:hover {
|
||||
background: rgb(var(--highlight-rgb), 0.08);
|
||||
}
|
||||
|
||||
.autoeq-database-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.autoeq-database-title {
|
||||
|
|
@ -9060,6 +9311,69 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Mobile parametric EQ band layout */
|
||||
.autoeq-band-control {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.autoeq-band-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.autoeq-band-number {
|
||||
min-width: 1.2rem;
|
||||
}
|
||||
|
||||
.autoeq-band-param {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.autoeq-band-sliders {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.autoeq-band-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.autoeq-band-slider::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.autoeq-band-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.autoeq-filters-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.autoeq-filters-actions button {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Rearrange band header into 2 rows on small screens */
|
||||
.autoeq-band-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr 1fr 1fr;
|
||||
gap: 0.25rem 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.autoeq-band-param {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Track List Search */
|
||||
|
|
@ -9959,3 +10273,543 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
.contrib {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Fullscreen layout rebuild on PR 378 base */
|
||||
#fullscreen-cover-overlay .fullscreen-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-main-view {
|
||||
width: min(1240px, 100%);
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 430px) minmax(420px, 1fr);
|
||||
gap: clamp(1.5rem, 3vw, 3rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(4rem, 7vh, 5rem) clamp(2rem, 4vw, 3rem) clamp(3rem, 6vh, 4rem) clamp(4rem, 7vw, 6.25rem);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-media-column,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-media-column {
|
||||
width: min(420px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
justify-self: center;
|
||||
transform: translateX(clamp(0.75rem, 1.2vw, 1.4rem));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-artwork-card {
|
||||
width: min(420px, 100%);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 28px 80px rgb(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-track-info {
|
||||
width: min(420px, 100%);
|
||||
display: block;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
padding: 0.15rem 0 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-track-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-track-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.15rem, 1.5vw, 1.42rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-track-artist {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 0.94rem;
|
||||
color: rgb(255 255 255 / 0.74);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #toggle-fullscreen-lyrics-btn,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-actions {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-actions .btn-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
color: rgb(255 255 255 / 0.74);
|
||||
background: transparent;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-actions .btn-icon:hover {
|
||||
color: rgb(255 255 255 / 0.96);
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-next-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.85rem;
|
||||
color: rgb(255 255 255 / 0.56);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-next-track .label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #fullscreen-next-track .value {
|
||||
font-size: 0.84rem;
|
||||
color: rgb(255 255 255 / 0.74);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: calc(1.5rem + env(safe-area-inset-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
background: rgb(9 12 18 / 0.34);
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn,
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn {
|
||||
position: static;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions button,
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions #close-fullscreen-cover-btn,
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions #fs-visualizer-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #toggle-ui-btn {
|
||||
top: 1.25rem;
|
||||
left: calc(80px + 2.3rem + env(safe-area-inset-left));
|
||||
right: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgb(9 12 18 / 0.34);
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
opacity: 1;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-controls {
|
||||
width: min(420px, 100%);
|
||||
margin-top: 0;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons button:hover {
|
||||
color: rgb(255 255 255 / 0.94);
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons button.active {
|
||||
color: rgb(var(--highlight-rgb) / 0.98);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: rgb(255 255 255 / 0.96);
|
||||
color: rgb(11 15 21 / 0.92);
|
||||
box-shadow: 0 12px 28px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn:hover {
|
||||
background: rgb(255 255 255 / 1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-volume-container {
|
||||
width: 238px;
|
||||
max-width: 100%;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.2rem;
|
||||
margin-inline: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-visualizer-btn,
|
||||
#fullscreen-cover-overlay .fs-volume-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
color: rgb(255 255 255 / 0.62);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-visualizer-btn:hover,
|
||||
#fullscreen-cover-overlay .fs-volume-btn:hover {
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-visualizer-btn.active {
|
||||
color: rgb(var(--highlight-rgb) / 0.96);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-btn {
|
||||
position: absolute;
|
||||
left: -2.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-btn:hover {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-bar {
|
||||
width: 238px;
|
||||
height: 4px;
|
||||
background: rgb(255 255 255 / 0.24);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-bar:hover {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-fill,
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-fill {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container {
|
||||
color: rgb(255 255 255 / 0.62);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar {
|
||||
height: 4px;
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill,
|
||||
#fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill {
|
||||
background: rgb(var(--highlight-rgb) / 0.94);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill::after,
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:active .progress-fill::after,
|
||||
#fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill::after,
|
||||
#fullscreen-cover-overlay .fs-volume-bar:active .fs-volume-fill::after {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-pane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-shell,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-shell {
|
||||
width: min(860px, 100%);
|
||||
min-height: 0;
|
||||
margin-left: clamp(4rem, 8vw, 8rem);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding-left: clamp(2.5rem, 4vw, 4rem);
|
||||
mask-image: none;
|
||||
overflow: visible;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics {
|
||||
--am-lyrics-highlight-color: #f6f4ef;
|
||||
--lyrics-scroll-padding-top: 18%;
|
||||
--lyplus-blur-amount: 0.16em;
|
||||
--lyplus-blur-amount-near: 0.085em;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: 'SF Pro Display', Inter, sans-serif;
|
||||
|
||||
--lyplus-font-size-base: clamp(34px, 3vw, 52px);
|
||||
--lyplus-padding-line: 8px;
|
||||
--lyplus-text-color: rgb(246, 244, 239, 0.08);
|
||||
--lyplus-active-color: #f6f4ef;
|
||||
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 600;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-empty,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-loading,
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-error {
|
||||
padding: clamp(5rem, 14vh, 7rem) 0 0 clamp(2rem, 5vw, 4.5rem);
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.lyrics-unavailable .fullscreen-lyrics-pane {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.queue-panel-active .fullscreen-main-view {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.queue-panel-active .fullscreen-media-column {
|
||||
justify-self: center;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.queue-panel-active .fullscreen-lyrics-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#fullscreen-cover-overlay .fullscreen-main-view {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(760px, 100%);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
padding: calc(4.5rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.5rem)
|
||||
calc(1.5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-media-column {
|
||||
justify-self: center;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-lyrics-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#fullscreen-cover-overlay {
|
||||
--fs-mobile-top-btn-size: 44px;
|
||||
--fs-mobile-top-btn-gap: 0.6rem;
|
||||
--fs-mobile-top-btn-left: calc(1rem + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-cover-content {
|
||||
padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions {
|
||||
top: calc(0.75rem + env(safe-area-inset-top));
|
||||
left: var(--fs-mobile-top-btn-left);
|
||||
gap: var(--fs-mobile-top-btn-gap);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-top-actions button,
|
||||
#fullscreen-cover-overlay #toggle-ui-btn {
|
||||
width: var(--fs-mobile-top-btn-size);
|
||||
height: var(--fs-mobile-top-btn-size);
|
||||
background: rgb(9 12 18 / 0.5);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay #toggle-ui-btn {
|
||||
top: calc(0.75rem + env(safe-area-inset-top));
|
||||
left: calc(
|
||||
var(--fs-mobile-top-btn-left) + (var(--fs-mobile-top-btn-size) * 2) + (var(--fs-mobile-top-btn-gap) * 2)
|
||||
);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-main-view {
|
||||
width: 100%;
|
||||
gap: 0.85rem;
|
||||
padding: calc(7.25rem + env(safe-area-inset-top)) 0.75rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-track-info,
|
||||
#fullscreen-cover-overlay .fullscreen-controls,
|
||||
#fullscreen-cover-overlay .fullscreen-media-column {
|
||||
width: min(100%, 460px);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-actions .btn-icon {
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-progress-container {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fullscreen-volume-container {
|
||||
width: min(220px, calc(100% - 2.75rem));
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-btn {
|
||||
left: -2.25rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay .fs-volume-bar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||