程式碼檢視器 – V-VideoPlayer-SBS-v1.py

← 返回清單
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())