import sys
from PySide6.QtCore import Qt, QUrl, QTimer
from PySide6.QtGui import QAction, QKeySequence, QPalette, QColor
from PySide6.QtWidgets import (
QApplication, QWidget, QFileDialog, QHBoxLayout, QVBoxLayout, QLabel,
QPushButton, QSlider, QMessageBox, QSizePolicy
)
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtMultimediaWidgets import QVideoWidget
def apply_dark_palette(app: QApplication):
app.setStyle("Fusion")
p = QPalette()
p.setColor(QPalette.Window, QColor(37, 37, 38))
p.setColor(QPalette.WindowText, Qt.white)
p.setColor(QPalette.Base, QColor(30, 30, 30))
p.setColor(QPalette.AlternateBase, QColor(45, 45, 48))
p.setColor(QPalette.ToolTipBase, Qt.white)
p.setColor(QPalette.ToolTipText, Qt.white)
p.setColor(QPalette.Text, Qt.white)
p.setColor(QPalette.Button, QColor(45, 45, 48))
p.setColor(QPalette.ButtonText, Qt.white)
p.setColor(QPalette.BrightText, Qt.red)
p.setColor(QPalette.Highlight, QColor(14, 99, 156))
p.setColor(QPalette.HighlightedText, Qt.white)
app.setPalette(p)
app.setStyleSheet("""
QWidget { color: #ddd; }
QPushButton { background:#3c3c3c; border:1px solid #555; padding:6px 10px; border-radius:6px; }
QPushButton:hover { background:#4a4a4a; }
QPushButton:pressed { background:#2f2f2f; }
QSlider::groove:horizontal { height:6px; background:#2b2b2b; border-radius:3px; }
QSlider::handle:horizontal { width:14px; height:14px; margin:-5px 0; border-radius:7px; background:#aaaaaa; }
QLabel { color:#ccc; }
""")
def ms_to_hhmmss(ms: int) -> str:
if ms < 0:
ms = 0
s = ms // 1000
h, m, s = s // 3600, (s % 3600) // 60, s % 60
return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
class DualVideoWindow(QWidget):
# 微調與硬同步的閾值與速率
SYNC_TICK_MS = 200
SOFT_THRESHOLD_MS = 60
HARD_THRESHOLD_MS = 250
RATE_FAST = 1.03
RATE_SLOW = 0.97
def __init__(self, left_path: str | None = None, right_path: str | None = None):
super().__init__()
self.setWindowTitle("影片播放器(左右並排)")
self.resize(1200, 640)
# --- 影片元件:左右貼齊、0px 間距 ---
self.left_video = QVideoWidget()
self.right_video = QVideoWidget()
# 保持比例、黑色底、無邊框
for vw in (self.left_video, self.right_video):
vw.setAspectRatioMode(Qt.KeepAspectRatio)
vw.setStyleSheet("background-color: black; border: 0; margin: 0; padding: 0;")
# --- 媒體核心(左/右) ---
self.left_player = QMediaPlayer(self)
self.right_player = QMediaPlayer(self)
self.left_audio = QAudioOutput(self)
self.right_audio = QAudioOutput(self)
self.left_player.setVideoOutput(self.left_video)
self.right_player.setVideoOutput(self.right_video)
self.left_player.setAudioOutput(self.left_audio)
self.right_player.setAudioOutput(self.right_audio)
self.left_audio.setVolume(0.6)
self.right_audio.setVolume(0.6)
# --- 控制列(共用) ---
self.btn_open_left = QPushButton("開啟左眼")
self.btn_open_right = QPushButton("開啟右眼")
self.btn_play = QPushButton("播放")
self.btn_stop = QPushButton("停止")
self.btn_mute = QPushButton("靜音")
self.progress = QSlider(Qt.Horizontal)
self.progress.setRange(0, 0)
self.progress.setSingleStep(1000)
self.progress.setPageStep(5000)
self.progress.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.time_label = QLabel("00:00 / 00:00")
self.time_label.setMinimumWidth(160)
self.vol_label = QLabel("音量")
self.vol = QSlider(Qt.Horizontal)
self.vol.setRange(0, 100)
self.vol.setValue(60)
self.vol.setFixedWidth(140)
self.btn_full = QPushButton("全螢幕")
# --- 版面配置:上方左右影片(0 spacing / 0 margins)、下方控制列 ---
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
videos = QHBoxLayout()
videos.setSpacing(0)
videos.setContentsMargins(0, 0, 0, 0)
videos.addWidget(self.left_video, 1)
videos.addWidget(self.right_video, 1)
root.addLayout(videos, 1)
bar = QHBoxLayout()
bar.setSpacing(8)
bar.addWidget(self.btn_open_left)
bar.addWidget(self.btn_open_right)
bar.addSpacing(6)
bar.addWidget(self.btn_play)
bar.addWidget(self.btn_stop)
bar.addSpacing(6)
bar.addWidget(self.progress, 1)
bar.addWidget(self.time_label)
bar.addSpacing(6)
bar.addWidget(self.btn_mute)
bar.addWidget(self.vol_label)
bar.addWidget(self.vol)
bar.addSpacing(6)
bar.addWidget(self.btn_full)
root.addLayout(bar)
# --- 事件連結 ---
self.btn_open_left.clicked.connect(lambda: self.open_file("left"))
self.btn_open_right.clicked.connect(lambda: self.open_file("right"))
self.btn_play.clicked.connect(self.toggle_play)
self.btn_stop.clicked.connect(self.stop_both)
self.btn_mute.clicked.connect(self.toggle_mute_both)
self.btn_full.clicked.connect(self.toggle_window_fullscreen)
self.progress.sliderMoved.connect(self.seek_to)
self.progress.sliderPressed.connect(self._pause_for_scrub)
self.progress.sliderReleased.connect(self._resume_after_scrub)
self.vol.valueChanged.connect(self.on_volume_both)
self.left_player.positionChanged.connect(self._on_position_any)
self.right_player.positionChanged.connect(self._on_position_any)
self.left_player.durationChanged.connect(self._on_duration_any)
self.right_player.durationChanged.connect(self._on_duration_any)
self.left_player.errorOccurred.connect(self._on_error_left)
self.right_player.errorOccurred.connect(self._on_error_right)
self._make_shortcuts()
# 雙擊任一畫面 → 全螢幕
self.left_video.mouseDoubleClickEvent = lambda e: self.toggle_window_fullscreen()
self.right_video.mouseDoubleClickEvent = lambda e: self.toggle_window_fullscreen()
# --- 精準校時(drift 校正)計時器 ---
self._syncing = False
self._sync_timer = QTimer(self)
self._sync_timer.setInterval(self.SYNC_TICK_MS)
self._sync_timer.timeout.connect(self._sync_tick)
# 啟動參數載入
if left_path:
self._load_and_prepare("left", left_path)
if right_path:
self._load_and_prepare("right", right_path)
self._update_window_title()
# ---------------- 基本工具 ----------------
def _any_player_loaded(self) -> bool:
return (self.left_player.source().isValid() if hasattr(self.left_player, "source") else False) or \
(self.right_player.source().isValid() if hasattr(self.right_player, "source") else False)
def _both_loaded(self) -> bool:
return self.left_player.source().isValid() and self.right_player.source().isValid()
def _update_window_title(self):
lt = self.left_player.source().toLocalFile() if self.left_player.source().isValid() else ""
rt = self.right_player.source().toLocalFile() if self.right_player.source().isValid() else ""
name_l = lt.split("/")[-1] if lt else "(未載入)"
name_r = rt.split("/")[-1] if rt else "(未載入)"
self.setWindowTitle(f"影片播放器(左右並排) - 左:{name_l} | 右:{name_r}")
# ---------------- 載入/開啟 ----------------
def open_file(self, side: str):
path, _ = QFileDialog.getOpenFileName(
self, "選擇影片檔案", "", "影片檔案 (*.mp4 *.mov *.mkv *.avi *.m4v);;所有檔案 (*)"
)
if path:
self._load_and_prepare(side, path)
def _load_and_prepare(self, side: str, path: str):
if side == "left":
self.left_player.setSource(QUrl.fromLocalFile(path))
else:
self.right_player.setSource(QUrl.fromLocalFile(path))
self._update_window_title()
# 不自動播放,等使用者按「播放」以利同步
self.btn_play.setText("播放")
# 載入新檔時重置播放速率
self._restore_rates()
# ---------------- 播放控制(左右同步) ----------------
def toggle_play(self):
if not self._any_player_loaded():
return
playing = (self.left_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState) or \
(self.right_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState)
if playing:
self.left_player.pause()
self.right_player.pause()
self.btn_play.setText("播放")
self._sync_timer.stop()
self._restore_rates()
else:
target = self.progress.value()
self.left_player.setPosition(target)
self.right_player.setPosition(target)
self._restore_rates()
self.left_player.play()
self.right_player.play()
self.btn_play.setText("暫停")
self._sync_timer.start()
def stop_both(self):
self.left_player.stop()
self.right_player.stop()
self.btn_play.setText("播放")
self._sync_timer.stop()
self._restore_rates()
def toggle_mute_both(self):
new_state = not (self.left_audio.isMuted() and self.right_audio.isMuted())
self.left_audio.setMuted(new_state)
self.right_audio.setMuted(new_state)
self.btn_mute.setText("解除靜音" if new_state else "靜音")
def on_volume_both(self, v: int):
self.left_audio.setVolume(v / 100.0)
self.right_audio.setVolume(v / 100.0)
if v == 0:
self.left_audio.setMuted(True)
self.right_audio.setMuted(True)
self.btn_mute.setText("解除靜音")
else:
self.left_audio.setMuted(False)
self.right_audio.setMuted(False)
self.btn_mute.setText("靜音")
def _pause_for_scrub(self):
self._was_playing = (
self.left_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState or
self.right_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
)
if self._was_playing:
self.left_player.pause()
self.right_player.pause()
self._sync_timer.stop()
self._restore_rates()
def _resume_after_scrub(self):
if getattr(self, "_was_playing", False):
self.left_player.play()
self.right_player.play()
self._sync_timer.start()
def seek_to(self, slider_value: int):
self.left_player.setPosition(slider_value)
self.right_player.setPosition(slider_value)
# ---------------- 精準校時核心 ----------------
def _restore_rates(self):
try:
self.left_player.setPlaybackRate(1.0)
self.right_player.setPlaybackRate(1.0)
except Exception:
pass
def _sync_tick(self):
if self._syncing:
return
if not self._both_loaded():
return
# 只在雙方都在播放時處理
if not (self.left_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState and
self.right_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState):
return
lp = self.left_player.position()
rp = self.right_player.position()
diff = lp - rp # >0 代表左邊超前
try:
self._syncing = True
adiff = abs(diff)
# 影片總長度為 0(直播或未知)時,略過硬同步避免跳動
if self.left_player.duration() == 0 and self.right_player.duration() == 0:
return
if adiff >= self.HARD_THRESHOLD_MS:
# 直接硬同步:將落後者定位到超前者
target = max(lp, rp)
if diff > 0:
# 左超前 → 右追上
self.right_player.setPosition(target)
else:
# 右超前 → 左追上
self.left_player.setPosition(target)
self._restore_rates()
elif adiff >= self.SOFT_THRESHOLD_MS:
# 微調播放速率追平
if diff > 0:
# 左超前 → 右加速/左減速
self.left_player.setPlaybackRate(self.RATE_SLOW)
self.right_player.setPlaybackRate(self.RATE_FAST)
else:
# 右超前 → 左加速/右減速
self.left_player.setPlaybackRate(self.RATE_FAST)
self.right_player.setPlaybackRate(self.RATE_SLOW)
else:
# 幾乎同步 → 還原 1.0×
self._restore_rates()
finally:
self._syncing = False
# ---------------- 狀態更新(時間/長度顯示) ----------------
def _primary_player(self) -> QMediaPlayer | None:
if self.left_player.source().isValid():
return self.left_player
if self.right_player.source().isValid():
return self.right_player
return None
def _on_position_any(self, _pos_ms: int):
primary = self._primary_player()
if primary is None:
return
if not self.progress.isSliderDown():
self.progress.setValue(primary.position())
total = max(self.left_player.duration(), self.right_player.duration())
self.time_label.setText(f"{ms_to_hhmmss(primary.position())} / {ms_to_hhmmss(total)}")
def _on_duration_any(self, _dur_ms: int):
total = max(self.left_player.duration(), self.right_player.duration())
self.progress.setRange(0, total)
# ---------------- 錯誤處理 ----------------
def _on_error_left(self, err, what):
if err != QMediaPlayer.Error.NoError:
QMessageBox.critical(self, "播放錯誤(左)", f"發生錯誤:{what}")
def _on_error_right(self, err, what):
if err != QMediaPlayer.Error.NoError:
QMessageBox.critical(self, "播放錯誤(右)", f"發生錯誤:{what}")
# ---------------- 全螢幕(視窗) ----------------
def toggle_window_fullscreen(self):
if self.windowState() & Qt.WindowFullScreen:
self.setWindowState(self.windowState() & ~Qt.WindowFullScreen)
else:
self.setWindowState(self.windowState() | Qt.WindowFullScreen)
# ---------------- 快捷鍵 ----------------
def _make_shortcuts(self):
def add_shortcut(key, func):
a = QAction(self); a.setShortcut(QKeySequence(key)); a.triggered.connect(func); self.addAction(a)
add_shortcut(Qt.Key_Space, self.toggle_play)
add_shortcut(Qt.Key_F, self.toggle_window_fullscreen)
add_shortcut(Qt.Key_Left, lambda: self.seek_to(max(0, self.progress.value() - 5000)))
add_shortcut(Qt.Key_Right, lambda: self.seek_to(min(self.progress.maximum(), self.progress.value() + 5000)))
add_shortcut(Qt.Key_Up, lambda: self.vol.setValue(min(100, self.vol.value() + 5)))
add_shortcut(Qt.Key_Down, lambda: self.vol.setValue(max(0, self.vol.value() - 5)))
if __name__ == "__main__":
app = QApplication(sys.argv)
apply_dark_palette(app)
left_path = None
right_path = None
if len(sys.argv) >= 2:
left_path = sys.argv[1]
if len(sys.argv) >= 3:
right_path = sys.argv[2]
w = DualVideoWindow(left_path, right_path)
w.show()
sys.exit(app.exec())