diff --git a/index.html b/index.html
index 9cf8929..e90deb6 100644
--- a/index.html
+++ b/index.html
@@ -3748,6 +3748,32 @@
+
+
+ Fullscreen Cover Tilt
+ 3D tilt effect on album cover in fullscreen view
+
+
+
+
+
+ Preload Next Track
+ Seconds before track ends to start loading next
+
+
+
diff --git a/js/player.js b/js/player.js
index de0b850..4e0ed01 100644
--- a/js/player.js
+++ b/js/player.js
@@ -16,6 +16,7 @@ import {
exponentialVolumeSettings,
audioEffectsSettings,
radioSettings,
+ playbackSettings,
} from './storage.js';
import { audioContextManager } from './audio-context.js';
import { isIos, isSafari } from './platform-detection.js';
@@ -48,6 +49,7 @@ export class Player {
this.repeatMode = REPEAT_MODE.OFF;
this.preloadCache = new Map();
this.preloadAbortController = null;
+ this._lastPreloadTime = null;
this.currentTrack = null;
this.currentRgValues = null;
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
@@ -106,7 +108,6 @@ export class Player {
bufferingGoal: 30,
rebufferingGoal: 2,
bufferBehind: 30,
- jumpLargeGaps: true,
},
abr: {
enabled: true,
@@ -150,7 +151,6 @@ export class Player {
document.addEventListener('visibilitychange', () => {
const el = this.activeElement;
if (document.visibilityState === 'visible' && !el.paused) {
- // Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) {
audioContextManager.init(el);
}
@@ -162,6 +162,17 @@ export class Player {
}
});
+ // Time-based preload trigger for Safari background playback
+ this._timeUpdateHandler = this._handleTimeUpdateForPreload.bind(this);
+ this.audio.addEventListener('timeupdate', this._timeUpdateHandler);
+ if (this.video) {
+ this.video.addEventListener('timeupdate', this._timeUpdateHandler);
+ }
+
+ window.addEventListener('preload-time-change', () => {
+ this._lastPreloadTime = null;
+ });
+
this._setupVideoSync();
}
@@ -516,6 +527,21 @@ export class Player {
}
}
+ _handleTimeUpdateForPreload() {
+ const el = this.activeElement;
+ if (!el || !el.duration || el.paused) return;
+
+ const preloadTime = playbackSettings.getPreloadTime();
+ const timeRemaining = el.duration - el.currentTime;
+ if (timeRemaining <= preloadTime && timeRemaining > 0) {
+ const now = Date.now();
+ if (!this._lastPreloadTime || now - this._lastPreloadTime > 5000) {
+ this._lastPreloadTime = now;
+ this.preloadNextTracks();
+ }
+ }
+ }
+
async setupHlsVideo(video, result, fallbackImg) {
const url = result.videoUrl || result.hlsUrl || result;
const Hls = (await import('hls.js')).default;
diff --git a/js/settings.js b/js/settings.js
index 9323dad..7cf8e44 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -18,6 +18,7 @@ import {
visualizerSettings,
playlistSettings,
equalizerSettings,
+ playbackSettings,
listenBrainzSettings,
malojaSettings,
libreFmSettings,
@@ -1111,6 +1112,27 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
+ // Fullscreen Cover Tilt Toggle
+ const fullscreenTiltToggle = document.getElementById('fullscreen-tilt-toggle');
+ if (fullscreenTiltToggle) {
+ fullscreenTiltToggle.checked = playbackSettings.isFullscreenTiltEnabled();
+ fullscreenTiltToggle.addEventListener('change', (e) => {
+ playbackSettings.setFullscreenTiltEnabled(e.target.checked);
+ window.dispatchEvent(new CustomEvent('fullscreen-tilt-toggle', { detail: { enabled: e.target.checked } }));
+ });
+ }
+
+ // Preload Time Input
+ const preloadTimeInput = document.getElementById('preload-time-input');
+ if (preloadTimeInput) {
+ preloadTimeInput.value = playbackSettings.getPreloadTime();
+ preloadTimeInput.addEventListener('change', (e) => {
+ const val = Math.max(5, Math.min(60, parseInt(e.target.value, 10) || 15));
+ playbackSettings.setPreloadTime(val);
+ window.dispatchEvent(new CustomEvent('preload-time-change', { detail: { seconds: val } }));
+ });
+ }
+
// ReplayGain Settings
const replayGainMode = document.getElementById('replay-gain-mode');
if (replayGainMode) {
diff --git a/js/storage.js b/js/storage.js
index 8ba7de3..e7b2035 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -999,6 +999,36 @@ export const visualizerSettings = {
},
};
+export const playbackSettings = {
+ FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt',
+ PRELOAD_TIME_KEY: 'playback-preload-time',
+
+ isFullscreenTiltEnabled() {
+ try {
+ return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+ },
+
+ setFullscreenTiltEnabled(enabled) {
+ localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false');
+ },
+
+ getPreloadTime() {
+ try {
+ const val = localStorage.getItem(this.PRELOAD_TIME_KEY);
+ return val ? parseInt(val, 10) : 15;
+ } catch {
+ return 15;
+ }
+ },
+
+ setPreloadTime(seconds) {
+ localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString());
+ },
+};
+
export const equalizerSettings = {
ENABLED_KEY: 'equalizer-enabled',
GAINS_KEY: 'equalizer-gains',
diff --git a/js/ui.js b/js/ui.js
index b709182..34375fd 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -26,6 +26,7 @@ import {
fontSettings,
contentBlockingSettings,
settingsUiState,
+ playbackSettings,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
@@ -148,6 +149,9 @@ export class UIRenderer {
this.lastRecommendedTracks = [];
this.currentArtistId = null;
+ this._handleTiltMove = this._handleTiltMove.bind(this);
+ this._handleTiltLeave = this._handleTiltLeave.bind(this);
+
// Listen for dynamic color reset events
window.addEventListener('reset-dynamic-color', () => {
this.resetVibrantColor();
@@ -1225,6 +1229,14 @@ export class UIRenderer {
overlay.style.display = 'flex';
+ // Apply vanilla-tilt effect to fullscreen cover if enabled
+ this._applyFullscreenTilt(overlay);
+
+ // Listen for tilt setting changes
+ window.addEventListener('fullscreen-tilt-toggle', (e) => {
+ this._applyFullscreenTilt(overlay, e.detail.enabled);
+ });
+
const startVisualizer = async () => {
if (!visualizerSettings.isEnabled()) {
if (this.visualizer) this.visualizer.stop();
@@ -1318,6 +1330,46 @@ export class UIRenderer {
clearTimeout(this.uiToggleMouseTimer);
this.uiToggleMouseTimer = null;
}
+
+ // Clean up vanilla-tilt if applied
+ this._removeFullscreenTilt();
+ }
+
+ _applyFullscreenTilt(overlay, enabled = playbackSettings.isFullscreenTiltEnabled()) {
+ const image = document.getElementById('fullscreen-cover-image');
+ if (!image) return;
+
+ this._removeFullscreenTilt();
+
+ if (!enabled) return;
+
+ image.addEventListener('mousemove', this._handleTiltMove);
+ image.addEventListener('mouseleave', this._handleTiltLeave);
+ }
+
+ _handleTiltMove(e) {
+ const image = e.target;
+ const rect = image.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+ const centerX = rect.width / 2;
+ const centerY = rect.height / 2;
+ const rotateX = ((y - centerY) / centerY) * -10;
+ const rotateY = ((x - centerX) / centerX) * 10;
+
+ image.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
+ }
+
+ _handleTiltLeave(e) {
+ e.target.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) scale(1)';
+ }
+
+ _removeFullscreenTilt() {
+ const image = document.getElementById('fullscreen-cover-image');
+ if (!image) return;
+ image.removeEventListener('mousemove', this._handleTiltMove);
+ image.removeEventListener('mouseleave', this._handleTiltLeave);
+ image.style.transform = '';
}
setupUIToggleButton(overlay) {