Skip to content

Commit 163323d

Browse files
committed
feat(linux)!: decouple video rendering from UI thread
Replace the direct renderer with a threaded offscreen renderer that decouples video rendering from UI rendering to improve UI responsiveness. Updated Video Rendering: - Windows: unchanged - mpvQC uses winId/HWND embedding as before - Linux: threaded offscreen renderer (replaces direct rendering) Technical Implementation: The new renderer uses double-buffered framebuffers with rendering in a dedicated background thread. The UI thread blits the latest completed frame during its render operations: ┌─────┐ ┌────────┐ ┌─────────┐ ┌────────┐ ┌────────┐ │ mpv │ → │ render │ → │ display │ → │ Qt │ → │ screen │ │ │ │ fbo │ │ fbo │ │ fbo │ │ │ └─────┘ └────────┘ └─────────┘ └────────┘ └────────┘ └─ pointer swap ─┘ │ └────────── Background thread ──────────┘ │ │ └── UI thread ┘ blit This allows the UI to remain responsive at the display's refresh rate while video frames render independently at the content's native framerate. Audio Delay Compensation: The double-buffered design introduces variable latency as frames wait in display fbo for the next Qt render cycle. Assuming Qt processes the render request on the next vsync cycle, frames wait on average half a display frame period before being blitted to screen. ┌─────────────────────────────────────────────────────────────────┐ │ Consider a 60Hz display (16.67ms frame time): │ │ │ │ Display vsync: | | │ │ Time (ms): 0 16.67 │ │ │ │ Frame arrives at t=5ms: │ │ | F | │ │ ←------11.67ms wait-----→ │ │ │ │ Frame arrives at t=12ms: │ │ | F | │ │ ←-4.67ms--→ │ │ │ │ Average wait = 16.67ms / 2 ≈ 8.33ms │ └─────────────────────────────────────────────────────────────────┘ To compensate, mpvQC automatically adjusts mpv's audio delay to half the current monitor's frame time: (1 / refresh_rate) / 2. This value updates dynamically when the window moves to a different monitor or when the refresh rate changes. 60Hz: 8.33ms 120Hz: 4.17ms 144Hz: 3.47ms 240Hz: 2.08ms 360Hz: 1.39ms BREAKING CHANGE: Linux users must update their mpv.conf via Options -> Edit mpv.conf by either restoring default values or removing the video-timing-offset option. The new renderer handles timing automatically through audio delay compensation.
1 parent 1c3f958 commit 163323d

File tree

10 files changed

+552
-101
lines changed

10 files changed

+552
-101
lines changed

data/config/mpv-linux.conf

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
# HQ preset, uses Spline36 for upscaling and Mitchell-Netravali for downscaling
66
profile=gpu-hq
77

8-
# Configure offset to match a frame rate of 60 fps; default 0.050
9-
# More info: https://github.com/mpvqc/mpvQC/issues/26
10-
video-timing-offset=0.016
11-
128
# Debanding is disabled because we don't want to alter the video while doing quality control
139
deband=no
1410

mpvqc/services/host_integration/__init__.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
#
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

5+
from __future__ import annotations
6+
57
import platform
68
from dataclasses import dataclass
9+
from typing import TYPE_CHECKING
710

811
from PySide6.QtCore import QEvent, QObject, Signal, Slot
12+
from PySide6.QtGui import QScreen
13+
14+
if TYPE_CHECKING:
15+
from PySide6.QtGui import QWindow
916

1017

1118
@dataclass(frozen=True)
@@ -28,18 +35,28 @@ class HostIntegrationService(QObject):
2835
DEFAULT_WINDOW_BUTTON_PREFERENCE = WindowButtonPreference(minimize=True, maximize=True, close=True)
2936

3037
display_zoom_factor_changed = Signal(float)
38+
refresh_rate_changed = Signal(float)
3139

3240
def __init__(self):
3341
super().__init__()
3442
self._zoom_factor = get_display_zoom_factor()
43+
self._refresh_rate = get_refresh_rate()
3544

3645
self._zoom_factor_change_listener = ZoomFactorChangeEventListener()
3746
self._zoom_factor_change_listener.device_pixel_ratio_changed.connect(self._invalidate_zoom_factor)
3847

3948
from mpvqc.utility import get_main_window
4049

41-
self._window = get_main_window()
50+
self._window: QWindow = get_main_window()
4251
self._window.installEventFilter(self._zoom_factor_change_listener)
52+
self._window.screenChanged.connect(self._on_screen_changed)
53+
54+
self._screen: QScreen = self._window.screen()
55+
self._screen.refreshRateChanged.connect(self._invalidate_refresh_rate)
56+
57+
@property
58+
def display_zoom_factor(self) -> float:
59+
return self._zoom_factor
4360

4461
@Slot()
4562
def _invalidate_zoom_factor(self, *_) -> None:
@@ -48,8 +65,21 @@ def _invalidate_zoom_factor(self, *_) -> None:
4865
self.display_zoom_factor_changed.emit(zoom_factor)
4966

5067
@property
51-
def display_zoom_factor(self) -> float:
52-
return self._zoom_factor
68+
def refresh_rate(self) -> float:
69+
return self._refresh_rate
70+
71+
@Slot(QScreen)
72+
def _on_screen_changed(self, screen: QScreen) -> None:
73+
self._screen.refreshRateChanged.disconnect(self._invalidate_refresh_rate)
74+
self._screen = screen
75+
self._screen.refreshRateChanged.connect(self._invalidate_refresh_rate)
76+
self._invalidate_refresh_rate()
77+
78+
@Slot(float)
79+
def _invalidate_refresh_rate(self, *_) -> None:
80+
if (refresh_rate := get_refresh_rate()) != self._refresh_rate:
81+
self._refresh_rate = refresh_rate
82+
self.refresh_rate_changed.emit(refresh_rate)
5383

5484
def get_window_button_preference(self) -> WindowButtonPreference:
5585
match platform.system():
@@ -67,6 +97,12 @@ def get_display_zoom_factor() -> float:
6797
return get_main_window().devicePixelRatio()
6898

6999

100+
def get_refresh_rate() -> float:
101+
from mpvqc.utility import get_main_window
102+
103+
return get_main_window().screen().refreshRate()
104+
105+
70106
def read_linux_window_button_preference() -> WindowButtonPreference:
71107
from mpvqc.services.host_integration.portals import SettingsPortal
72108

mpvqc/services/player.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import logging
88
import os
9+
import sys
910
from dataclasses import dataclass
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING
@@ -106,6 +107,10 @@ def init(self, win_id: int | None = None):
106107
self._mpv.observe_property("height", self._on_player_height_changed)
107108
self._mpv.observe_property("width", self._on_player_width_changed)
108109

110+
if sys.platform == "linux":
111+
self._host_integration.refresh_rate_changed.connect(self._on_refresh_rate_changed)
112+
self._on_refresh_rate_changed(self._host_integration.refresh_rate)
113+
109114
@property
110115
def mpv(self) -> MPV | None:
111116
return self._mpv
@@ -115,6 +120,11 @@ def _get_mpv_attr(self, attr: str) -> Any | None:
115120
return None
116121
return getattr(self._mpv, attr, None)
117122

123+
def _set_mpv_attr(self, attr: str, value: Any) -> None:
124+
if self._mpv is None:
125+
return
126+
setattr(self._mpv, attr, value)
127+
118128
@property
119129
def mpv_version(self) -> str:
120130
return self._get_mpv_attr("mpv_version") or ""
@@ -173,6 +183,14 @@ def current_time(self) -> int:
173183
def duration(self) -> float:
174184
return self._get_mpv_attr("duration") or 0.0
175185

186+
@property
187+
def is_paused(self) -> bool:
188+
return not self.is_playing
189+
190+
@property
191+
def is_playing(self) -> bool:
192+
return not self._mpv.pause and not self._mpv.idle_active if self._mpv else False
193+
176194
@property
177195
def _track_list(self) -> list[TrackListEntry]:
178196
if self._mpv is None:
@@ -230,6 +248,15 @@ def _on_player_width_changed(self, _, value: int) -> None:
230248
if value is not None:
231249
self.width_changed.emit(value)
232250

251+
@Slot(float)
252+
def _on_refresh_rate_changed(self, new_refresh_rate: float) -> None:
253+
if new_refresh_rate <= 0:
254+
return
255+
256+
audio_delay = (1 / new_refresh_rate) / 2
257+
logger.info("Setting mpv audio-delay to %.4f sec for %.2f Hz", audio_delay, new_refresh_rate)
258+
self._set_mpv_attr("audio_delay", audio_delay)
259+
233260
def move_mouse(self, x: int, y: int) -> None:
234261
zoom_factor = self._host_integration.display_zoom_factor
235262
x = int(x * zoom_factor)

mpvqc/startup.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def perform_startup():
1717

1818

1919
def configure_qt_application_data():
20+
import platform
21+
2022
from PySide6.QtCore import QCoreApplication
2123

2224
from mpvqc.build import get_build_info
@@ -27,6 +29,11 @@ def configure_qt_application_data():
2729
QCoreApplication.setOrganizationDomain(app.domain)
2830
QCoreApplication.setApplicationVersion(app.version)
2931

32+
if platform.system() == "Linux":
33+
from PySide6.QtGui import QGuiApplication, Qt
34+
35+
QGuiApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
36+
3037

3138
def configure_qt_style():
3239
from PySide6.QtQuickControls2 import QQuickStyle

mpvqc/views/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,12 @@
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

55
# ruff: noqa: F401
6-
from .player_framebuffer_object import MpvqcMpvFrameBufferObjectPyObject
7-
from .player_win_id import MpvWindowPyObject
6+
import logging
7+
import platform
8+
9+
logger = logging.getLogger(__name__)
10+
11+
if platform.system() == "Windows":
12+
from .player_win_id import MpvWindowPyObject
13+
else:
14+
from .player_framebuffer_object_offscreen import MpvqcMpvFrameBufferObjectPyObject

mpvqc/views/player_framebuffer_object.py

Lines changed: 0 additions & 85 deletions
This file was deleted.

0 commit comments

Comments
 (0)