feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)

feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)
This commit is contained in:
Frank Stellmacher
2026-04-18 11:25:41 +02:00
committed by GitHub
14 changed files with 95 additions and 0 deletions
+1
View File
@@ -3612,6 +3612,7 @@ dependencies = [
"thread-priority",
"tokio",
"url",
"webkit2gtk",
"windows 0.58.0",
"zbus 5.14.0",
]
+2
View File
@@ -54,6 +54,8 @@ libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
+30
View File
@@ -90,6 +90,35 @@ fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
}
}
/// WebKitGTK: `enable-smooth-scrolling` also drives deferred / kinetic wheel scrolling.
#[cfg(target_os = "linux")]
fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, enabled: bool) -> Result<(), String> {
win.with_webview(move |platform| {
use webkit2gtk::{SettingsExt, WebViewExt};
if let Some(settings) = platform.inner().settings() {
settings.set_enable_smooth_scrolling(enabled);
}
})
.map_err(|e| e.to_string())
}
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
#[tauri::command]
fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(win) = app_handle.get_webview_window("main") {
linux_webkit_apply_smooth_scrolling(&win, enabled)?;
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (enabled, app_handle);
}
Ok(())
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -2578,6 +2607,7 @@ pub fn run() {
cli_publish_server_list,
cli_publish_search_results,
set_window_decorations,
set_linux_webkit_smooth_scrolling,
no_compositing_mode,
is_tiling_wm_cmd,
register_global_shortcut,
+6
View File
@@ -139,6 +139,7 @@ function AppShell() {
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
@@ -151,6 +152,11 @@ function AppShell() {
invoke('set_window_decorations', { enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => {
if (!IS_LINUX) return;
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
}, [linuxWebkitKineticScroll]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
+2
View File
@@ -538,6 +538,8 @@ export const deTranslation = {
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
useCustomTitlebar: 'Eigene Titelleiste',
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)',
linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.',
discordAppleCovers: 'Cover über Apple Music für Discord laden',
discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.',
discordOptions: 'Erweiterte Discord-Optionen',
+2
View File
@@ -540,6 +540,8 @@ export const enTranslation = {
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
useCustomTitlebar: 'Custom title bar',
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
linuxWebkitSmoothScroll: 'Smooth wheel (Linux)',
linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.',
discordAppleCovers: 'Fetch covers from Apple Music for Discord',
discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.',
discordOptions: 'Advanced Discord options',
+2
View File
@@ -541,6 +541,8 @@ export const esTranslation = {
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
useCustomTitlebar: 'Barra de título personalizada',
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
discordAppleCovers: 'Obtener portadas de Apple Music para Discord',
discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.',
discordOptions: 'Opciones avanzadas de Discord',
+2
View File
@@ -534,6 +534,8 @@ export const frTranslation = {
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord',
+2
View File
@@ -533,6 +533,8 @@ export const nbTranslation = {
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen',
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
discordAppleCovers: 'Hent covere fra Apple Music til Discord',
+2
View File
@@ -533,6 +533,8 @@ export const nlTranslation = {
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord',
+2
View File
@@ -555,6 +555,8 @@ export const ruTranslation = {
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
linuxWebkitSmoothScroll: 'Плавное колесо (Linux)',
linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
+2
View File
@@ -529,6 +529,8 @@ export const zhTranslation = {
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
linuxWebkitSmoothScroll: '滚轮平滑(Linux',
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
+19
View File
@@ -1136,6 +1136,25 @@ export default function Settings() {
</div>
</>
)}
{IS_LINUX && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
<input
type="checkbox"
checked={auth.linuxWebkitKineticScroll}
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
+21
View File
@@ -8,6 +8,7 @@ import {
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
import { usePlayerStore } from './playerStore';
import { IS_LINUX } from '../utils/platform';
export interface ServerProfile {
id: string;
@@ -65,6 +66,8 @@ interface AuthState {
discordTemplateState: string;
discordTemplateLargeText: string;
useCustomTitlebar: boolean;
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
linuxWebkitKineticScroll: boolean;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
@@ -209,6 +212,7 @@ interface AuthState {
setDiscordTemplateState: (v: string) => void;
setDiscordTemplateLargeText: (v: string) => void;
setUseCustomTitlebar: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
@@ -314,6 +318,7 @@ export const useAuthStore = create<AuthState>()(
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
linuxWebkitKineticScroll: true,
nowPlayingEnabled: false,
lyricsServerFirst: true,
enableNeteaselyrics: false,
@@ -443,6 +448,7 @@ export const useAuthStore = create<AuthState>()(
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
@@ -630,6 +636,20 @@ export const useAuthStore = create<AuthState>()(
}
} catch { /* ignore */ }
// One-time: older builds could persist smooth=false as the default. Force smooth on once
// so updates do not leave users on discrete scrolling; after this flag exists, only an
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
const wheelSmoothMigrationKey = 'psysonic-linux-webkit-smooth-v1';
let wheelSmoothOneTime: { linuxWebkitKineticScroll?: boolean } = {};
if (IS_LINUX) {
try {
if (!localStorage.getItem(wheelSmoothMigrationKey)) {
wheelSmoothOneTime = { linuxWebkitKineticScroll: true };
localStorage.setItem(wheelSmoothMigrationKey, '1');
}
} catch { /* ignore */ }
}
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -639,6 +659,7 @@ export const useAuthStore = create<AuthState>()(
),
...conflictingLegacyState,
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
});
},
}