#!/usr/bin/env python3
"""
TranscribeStation
Gestionnaire de transcription audio avec support pédaliers
Olympus RS27H/N · RS28H/N · RS31H/N

Dépendances :
    pip install PySide6 numpy soundfile hid

Règle udev nécessaire pour les pédaliers (sans root) :
    /etc/udev/rules.d/99-olympus-pedal.rules
    -----------------------------------------------
    SUBSYSTEM=="hidraw", ATTRS{idVendor}=="07b4", MODE="0666", GROUP="plugdev"
    SUBSYSTEM=="hidraw", ATTRS{idVendor}=="33a2", MODE="0666", GROUP="plugdev"
    SUBSYSTEM=="input",  ATTRS{idVendor}=="07b4", ENV{LIBINPUT_IGNORE_DEVICE}="1"
    SUBSYSTEM=="input",  ATTRS{idVendor}=="33a2", ENV{LIBINPUT_IGNORE_DEVICE}="1"
    Puis : sudo udevadm control --reload-rules && sudo udevadm trigger
"""

# ─── Métadonnées application ──────────────────────────────────────────────────
APP_NAME    = "TranscribeStation"
APP_VERSION = "1.2.0"
APP_AUTHOR  = "H3Campus"
APP_YEAR    = "Mars 2026"
APP_URL     = "https://github.com/h3campus/transcribe-station"

import sys
import os
import json
import time
from pathlib import Path
from datetime import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, List, Dict

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
    QTableWidget, QTableWidgetItem, QHeaderView, QVBoxLayout, QHBoxLayout,
    QToolBar, QStatusBar, QLabel, QSlider, QComboBox, QPushButton, QSizePolicy,
    QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
    QFileDialog, QMessageBox, QMenu, QGroupBox, QPlainTextEdit, QInputDialog,
    QAbstractItemView, QTabWidget,
)
from PySide6.QtCore import (
    Qt, QUrl, QTimer, QThread, Signal, QSize, QRect,
    Slot,
)
from PySide6.QtGui import (
    QIcon, QAction, QColor, QPainter, QPen, QFont,
    QLinearGradient, QPixmap,
)
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput

try:
    import hid
    hid.enumerate()   # vérifie que la DLL native est chargeable
    HID_AVAILABLE = True
except (ImportError, OSError, Exception):
    HID_AVAILABLE = False


class _HidDevice:
    """
    Wrapper de compatibilité HID — supporte trois variantes du module 'hid' :

    • Nouvelle API  (hid >= 1.0.6) : hid.Device(vid=…, pid=…)  +  dev.nonblocking
    • API OO classique              : hid.device()  +  dev.open()  +  dev.set_nonblocking()
    • API fonctionnelle             : hid.open()  /  hid.read()  /  hid.close()
    """
    if HID_AVAILABLE:
        if hasattr(hid, "Device"):
            _API = "new"       # hid.Device (constructeur ouvre directement)
        elif hasattr(hid, "device"):
            _API = "oo"        # hid.device() + open()
        else:
            _API = "func"      # hid.open() fonctionnel
    else:
        _API = "none"

    def __init__(self):
        self._dev = None

    def open(self, vid: int, pid: int, path: bytes = None) -> None:
        """Ouvre le device par VID/PID ou par chemin d'interface (path)."""
        api = self._API
        if api == "new":
            self._dev = hid.Device(path=path) if path else hid.Device(vid=vid, pid=pid)
        elif api == "oo":
            self._dev = hid.device()
            if path:
                self._dev.open_path(path)
            else:
                self._dev.open(vid, pid)
        elif api == "func":
            self._dev = hid.open_path(path) if path else hid.open(vid, pid)
        else:
            raise RuntimeError("Module hid non disponible")

    @staticmethod
    def paths_for(vid: int, pid: int) -> list:
        """
        Retourne les interfaces HID uniques pour un VID/PID.
        Chaque entrée : {'path': bytes, 'interface': int, 'usage_page': int, 'usage': int}
        """
        if not HID_AVAILABLE:
            return []
        seen:  set  = set()
        result: List = []
        try:
            for d in hid.enumerate():
                if d["vendor_id"] != vid or d["product_id"] != pid:
                    continue
                path = d.get("path", b"")
                if path and path not in seen:
                    seen.add(path)
                    result.append({
                        "path":       path,
                        "interface":  d.get("interface_number", 0),
                        "usage_page": d.get("usage_page", 0),
                        "usage":      d.get("usage", 0),
                    })
        except Exception:
            pass
        return result

    def set_nonblocking(self, value: bool) -> None:
        api = self._API
        if api == "new":
            self._dev.nonblocking = bool(value)
        elif api == "oo":
            self._dev.set_nonblocking(value)
        elif api == "func":
            hid.set_nonblocking(self._dev, 1 if value else 0)

    def read(self, length: int, timeout_ms: int = 0) -> list:
        api = self._API
        if api == "new":
            # La nouvelle API n'a pas de timeout_ms dans read() ;
            # le comportement bloquant/non-bloquant est géré par nonblocking.
            return list(self._dev.read(length))
        elif api == "oo":
            raw = self._dev.read(length, timeout_ms) if timeout_ms else self._dev.read(length)
            return list(raw)
        elif api == "func":
            return list(hid.read(self._dev, length))
        return []

    def close(self) -> None:
        try:
            if self._dev is not None:
                if self._API == "func":
                    hid.close(self._dev)
                else:
                    self._dev.close()
        except Exception:
            pass
        self._dev = None

try:
    import numpy as np
    import soundfile as sf
    WAVEFORM_AVAILABLE = True
except ImportError:
    WAVEFORM_AVAILABLE = False

# ─── Paramètres JSON ──────────────────────────────────────────────────────────
class AppSettings:
    """
    Paramètres persistants stockés dans ~/.config/TranscribeStation/settings.json
    Accès par clé pointée : get("pedal.enabled") / set("pedal.saved_profiles", {...})
    """
    CONFIG_PATH = (
        Path(os.environ.get("APPDATA", Path.home())) / "TranscribeStation" / "settings.json"
        if sys.platform == "win32"
        else Path.home() / ".config" / "TranscribeStation" / "settings.json"
    )
    MAX_RECENT  = 8

    DEFAULTS: Dict = {
        "pedal": {
            "enabled": True,
            "skip_ms": 3000,
            # Profils par pédalier : clé = "vid_hex:pid_hex" (ex. "07b4:0110")
            # Valeur : {name, vid, pid, buttons: {action:[bidx,val]}, hid_interface}
            "saved_profiles": {},
        },
        "general": {
            "author":    "",
            "auto_play": False,
            "theme":     "light",
        },
        "window": {
            "x": 100, "y": 100,
            "width": 1280, "height": 800,
        },
        "recent": [],   # liste de chemins récemment ouverts
    }

    def __init__(self, path: Optional[Path] = None):
        self._path = path or self.CONFIG_PATH
        self._data: Dict = {}
        self.load()

    # ── I/O ───────────────────────────────────────────────────────────────────
    def load(self):
        disk: Dict = {}
        if self._path.exists():
            try:
                with open(self._path, encoding="utf-8") as f:
                    disk = json.load(f)
            except Exception:
                disk = {}
        self._data = self._merge(self._copy_defaults(), disk)
        self._migrate_old_pedal_settings()

    def _migrate_old_pedal_settings(self):
        """Migre l'ancien format (un seul pédalier) vers saved_profiles."""
        pedal = self._data.get("pedal", {})
        old_vid     = pedal.get("vid")
        old_pid     = pedal.get("pid")
        old_buttons = pedal.get("buttons")
        old_iface   = pedal.get("hid_interface")
        old_model   = pedal.get("model")
        if old_vid and old_pid and isinstance(old_buttons, dict):
            key = f"{old_vid:04x}:{old_pid:04x}"
            profiles = pedal.setdefault("saved_profiles", {})
            if key not in profiles:
                profiles[key] = {
                    "name":          old_model or key,
                    "vid":           old_vid,
                    "pid":           old_pid,
                    "buttons":       old_buttons,
                    "hid_interface": old_iface,
                }
        for k in ("vid", "pid", "buttons", "hid_interface", "model"):
            pedal.pop(k, None)
        self._data["pedal"] = pedal

    def save(self):
        self._path.parent.mkdir(parents=True, exist_ok=True)
        with open(self._path, "w", encoding="utf-8") as f:
            json.dump(self._data, f, ensure_ascii=False, indent=2)

    # ── Accès par clé pointée ─────────────────────────────────────────────────
    def get(self, key: str, default=None):
        node = self._data
        for part in key.split("."):
            if isinstance(node, dict) and part in node:
                node = node[part]
            else:
                return default
        return node

    def set(self, key: str, value):
        parts = key.split(".")
        node  = self._data
        for part in parts[:-1]:
            node = node.setdefault(part, {})
        node[parts[-1]] = value
        self.save()

    # ── Dossiers récents ──────────────────────────────────────────────────────
    def add_recent(self, folder: Path):
        path_str = str(folder)
        recent: List[str] = self.get("recent", [])
        if path_str in recent:
            recent.remove(path_str)
        recent.insert(0, path_str)
        self.set("recent", recent[: self.MAX_RECENT])

    def get_recent(self) -> List[Path]:
        return [Path(p) for p in self.get("recent", []) if Path(p).is_dir()]

    # ── Géométrie fenêtre ─────────────────────────────────────────────────────
    def save_geometry(self, x: int, y: int, w: int, h: int):
        self._data["window"] = {"x": x, "y": y, "width": w, "height": h}
        self.save()

    def load_geometry(self) -> tuple:
        win = self.get("window", {})
        return (
            win.get("x", 100), win.get("y", 100),
            win.get("width", 1280), win.get("height", 800),
        )

    # ── Helpers ───────────────────────────────────────────────────────────────
    def _copy_defaults(self) -> Dict:
        import copy
        return copy.deepcopy(self.DEFAULTS)

    @staticmethod
    def _merge(base: Dict, override: Dict) -> Dict:
        for k, v in override.items():
            if k in base and isinstance(base[k], dict) and isinstance(v, dict):
                base[k] = AppSettings._merge(base[k], v)
            else:
                base[k] = v
        return base
OLYMPUS_VID = 0x07B4
OMDS_VID    = 0x33A2   # OM Digital Solutions (ex-Olympus, depuis 2022)
KNOWN_VIDS  = {OLYMPUS_VID, OMDS_VID}

# Format buttons: {action: [byte_idx, mask]}
# action ∈ {"rewind", "play", "forward"}
# byte_idx : indice dans le rapport HID brut (0-based)
# mask     : masque de bit (ex: 0x04)
PEDAL_PROFILES: Dict[str, Dict] = {
    "RS27H/N": {
        "vendor_id":  0x07B4,
        "product_id": 0x0110,
        "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]},
    },
    "RS28H/N": {
        "vendor_id":  0x07B4,
        "product_id": 0x0111,
        "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]},
    },
    "RS28H (0x0218)": {
        "vendor_id":  0x07B4,
        "product_id": 0x0218,
        "buttons": {"rewind": [1, 0x01], "play": [1, 0x02], "forward": [1, 0x04]},
    },
    "RS31H/N": {
        "vendor_id":  0x07B4,
        "product_id": 0x0112,
        "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]},
    },
    "OM RS Series (0x33A2)": {
        "vendor_id":  0x33A2,
        "product_id": 0x0293,
        "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]},
    },
}
# Délai de rembobinage/avance rapide en ms
SKIP_MS = 3000

# ─── Couleurs & thème ─────────────────────────────────────────────────────────
THEMES: Dict[str, Dict[str, str]] = {
    "light": {
        "bg":           "#F6F8FC",
        "surface":      "#FFFFFF",
        "surface2":     "#EEF3FB",
        "border":       "#D6DEEC",
        "accent":       "#2E6BE6",
        "accent_hover": "#4F84EC",
        "success":      "#1FA971",
        "warning":      "#D98B12",
        "error":        "#D14B4B",
        "pending":      "#D06A44",
        "text":         "#1F2937",
        "text_dim":     "#66758A",
        "waveform":     "#7EA7F1",
        "waveform_pos": "#24B47E",
        "selected_row": "#DCE8FF",
        "header_bg":    "#E9F0FB",
    },
    "dark": {
        "bg":           "#1E1E2E",
        "surface":      "#252535",
        "surface2":     "#2A2A3E",
        "border":       "#3A3A5C",
        "accent":       "#5B8DEF",
        "accent_hover": "#7AAEFF",
        "success":      "#3ECF8E",
        "warning":      "#F5A623",
        "error":        "#E05555",
        "pending":      "#E07B55",
        "text":         "#E8E8F0",
        "text_dim":     "#888899",
        "waveform":     "#5B8DEF",
        "waveform_pos": "#3ECF8E",
        "selected_row": "#2E3C5A",
        "header_bg":    "#1A1A2A",
    },
}

COLORS: Dict[str, str] = dict(THEMES["light"])
STYLESHEET = ""

def _build_stylesheet(colors: Dict[str, str]) -> str:
    return f"""
QMainWindow, QWidget {{
    background-color: {colors['bg']};
    color: {colors['text']};
    font-family: 'Segoe UI', 'DejaVu Sans', 'Liberation Sans', sans-serif;
    font-size: 13px;
}}
QMenuBar {{
    background: {colors['surface']};
    border-bottom: 1px solid {colors['border']};
    padding: 2px;
}}
QMenuBar::item {{ padding: 4px 10px; border-radius: 4px; }}
QMenuBar::item:selected {{ background: {colors['accent']}; }}
QMenu {{
    background: {colors['surface']};
    border: 1px solid {colors['border']};
    border-radius: 6px;
    padding: 4px;
}}
QMenu::item {{ padding: 6px 20px 6px 12px; border-radius: 4px; }}
QMenu::item:selected {{ background: {colors['accent']}; }}
QToolBar {{
    background: {colors['surface']};
    border-bottom: 1px solid {colors['border']};
    spacing: 4px;
    padding: 4px;
}}
QToolButton {{
    background: transparent;
    border: 1px solid transparent;
    border-radius: 5px;
    padding: 4px 8px;
    color: {colors['text']};
}}
QToolButton:hover {{ background: {colors['surface2']}; border-color: {colors['border']}; }}
QToolButton:pressed {{ background: {colors['accent']}; }}
QTreeWidget {{
    background: {colors['surface']};
    border: none;
    border-right: 1px solid {colors['border']};
    outline: none;
}}
QTreeWidget::item {{ padding: 4px 6px; border-radius: 4px; }}
QTreeWidget::item:selected {{ background: {colors['accent']}; color: white; }}
QTreeWidget::item:hover:!selected {{ background: {colors['surface2']}; }}
QTreeWidget::branch:has-children:!has-siblings:closed,
QTreeWidget::branch:closed:has-children:has-siblings {{
    image: url(none);
}}
QHeaderView::section {{
    background: {colors['header_bg']};
    color: {colors['text_dim']};
    border: none;
    border-bottom: 2px solid {colors['accent']};
    border-right: 1px solid {colors['border']};
    padding: 6px 8px;
    font-weight: 600;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}}
QTableWidget {{
    background: {colors['bg']};
    gridline-color: {colors['border']};
    border: none;
    outline: none;
    selection-background-color: {colors['selected_row']};
}}
QTableWidget::item {{ padding: 6px 8px; border-bottom: 1px solid {colors['border']}; }}
QTableWidget::item:selected {{
    background: {colors['selected_row']};
    color: {colors['text']};
}}
QScrollBar:vertical {{
    background: {colors['surface']};
    width: 8px;
    border-radius: 4px;
}}
QScrollBar::handle:vertical {{
    background: {colors['border']};
    border-radius: 4px;
    min-height: 20px;
}}
QScrollBar::handle:vertical:hover {{ background: {colors['accent']}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{
    background: {colors['surface']};
    height: 8px;
    border-radius: 4px;
}}
QScrollBar::handle:horizontal {{
    background: {colors['border']};
    border-radius: 4px;
    min-width: 20px;
}}
QSlider::groove:horizontal {{
    background: {colors['border']};
    height: 4px;
    border-radius: 2px;
}}
QSlider::sub-page:horizontal {{
    background: {colors['accent']};
    height: 4px;
    border-radius: 2px;
}}
QSlider::handle:horizontal {{
    background: white;
    border: 2px solid {colors['accent']};
    width: 14px;
    height: 14px;
    border-radius: 7px;
    margin: -5px 0;
}}
QSlider::handle:horizontal:hover {{ background: {colors['accent']}; }}
QPushButton {{
    background: {colors['surface2']};
    border: 1px solid {colors['border']};
    border-radius: 6px;
    padding: 6px 16px;
    color: {colors['text']};
    font-weight: 500;
}}
QPushButton:hover {{ background: {colors['accent']}; border-color: {colors['accent']}; color: white; }}
QPushButton:pressed {{ background: {colors['accent_hover']}; }}
QPushButton#accent {{
    background: {colors['accent']};
    border-color: {colors['accent']};
    color: white;
}}
QPushButton#accent:hover {{ background: {colors['accent_hover']}; }}
QComboBox {{
    background: {colors['surface2']};
    border: 1px solid {colors['border']};
    border-radius: 6px;
    padding: 4px 10px;
    min-width: 80px;
}}
QComboBox::drop-down {{ border: none; width: 20px; }}
QComboBox QAbstractItemView {{
    background: {colors['surface']};
    border: 1px solid {colors['border']};
    selection-background-color: {colors['accent']};
}}
QStatusBar {{
    background: {colors['surface']};
    border-top: 1px solid {colors['border']};
    color: {colors['text_dim']};
    font-size: 11px;
    padding: 2px 8px;
}}
QGroupBox {{
    border: 1px solid {colors['border']};
    border-radius: 6px;
    margin-top: 8px;
    padding-top: 8px;
    font-weight: 600;
    color: {colors['text_dim']};
}}
QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 4px; }}
QLabel {{ color: {colors['text']}; }}
QLineEdit, QSpinBox {{
    background: {colors['surface2']};
    border: 1px solid {colors['border']};
    border-radius: 6px;
    padding: 5px 10px;
    color: {colors['text']};
}}
QLineEdit:focus, QSpinBox:focus {{ border-color: {colors['accent']}; }}
QDialog {{
    background: {colors['bg']};
}}
QTabWidget::pane {{
    border: 1px solid {colors['border']};
    border-radius: 6px;
}}
QTabBar::tab {{
    background: {colors['surface']};
    border: 1px solid {colors['border']};
    padding: 6px 16px;
    border-top-left-radius: 6px;
    border-top-right-radius: 6px;
}}
QTabBar::tab:selected {{ background: {colors['accent']}; border-color: {colors['accent']}; color: white; }}
"""

def set_theme(theme_name: str) -> str:
    global COLORS, STYLESHEET
    actual = theme_name if theme_name in THEMES else "light"
    COLORS = dict(THEMES[actual])
    STYLESHEET = _build_stylesheet(COLORS)
    return actual

set_theme("light")

# ─── Modèle de données ────────────────────────────────────────────────────────
class DictationStatus(Enum):
    TODO       = "À faire"
    IN_PROGRESS = "En cours"
    SUSPENDED  = "Suspendu"
    DONE       = "Terminé"

@dataclass
class DictationFile:
    path: Path
    job_number: int = 0
    author: str = ""
    work_type: str = "GENERAL"
    status: DictationStatus = DictationStatus.TODO
    created: datetime = field(default_factory=datetime.now)
    completed: Optional[datetime] = None
    duration_s: float = 0.0
    notes: str = ""

    @property
    def filename(self) -> str:
        return self.path.name

    @property
    def duration_str(self) -> str:
        h, r = divmod(int(self.duration_s), 3600)
        m, s = divmod(r, 60)
        if h:
            return f"{h:02d}:{m:02d}:{s:02d}"
        return f"{m:02d}:{s:02d}"

    @property
    def status_color(self) -> str:
        return {
            DictationStatus.TODO:        COLORS["warning"],
            DictationStatus.IN_PROGRESS: COLORS["accent"],
            DictationStatus.SUSPENDED:   COLORS["text_dim"],
            DictationStatus.DONE:        COLORS["success"],
        }[self.status]

    def to_dict(self) -> dict:
        return {
            "path":       str(self.path),
            "job_number": self.job_number,
            "author":     self.author,
            "work_type":  self.work_type,
            "status":     self.status.name,
            "created":    self.created.isoformat(),
            "completed":  self.completed.isoformat() if self.completed else None,
            "duration_s": self.duration_s,
            "notes":      self.notes,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "DictationFile":
        obj = cls(path=Path(d["path"]))
        obj.job_number = d.get("job_number", 0)
        obj.author     = d.get("author", "")
        obj.work_type  = d.get("work_type", "GENERAL")
        obj.status     = DictationStatus[d.get("status", "TODO")]
        obj.created    = datetime.fromisoformat(d["created"])
        obj.completed  = datetime.fromisoformat(d["completed"]) if d.get("completed") else None
        obj.duration_s = d.get("duration_s", 0.0)
        obj.notes      = d.get("notes", "")
        return obj


# ─── Thread pédalier USB HID ──────────────────────────────────────────────────
class FootPedalWorker(QThread):
    """Lit les événements HID du pédalier en arrière-plan."""
    pedal_pressed  = Signal(str)   # "rewind" | "play" | "forward"
    pedal_released = Signal(str)
    device_status  = Signal(str)   # message statut

    def __init__(self, profile: Dict, parent=None):
        super().__init__(parent)
        self._profile = profile
        self._running = False
        self._device  = None

    @staticmethod
    def _match(prev_byte: int, curr_byte: int, value: int) -> tuple:
        """
        Détecte presse / relâchement pour un binding donné.

        Si 'value' est une puissance de 2 → mode bitmask (bit set/clear).
        Sinon → mode valeur exacte (key code, consumer usage…).
        Retourne (was_active, now_active).
        """
        if value > 0 and (value & (value - 1)) == 0:
            # Bitmask : un seul bit
            return bool(prev_byte & value), bool(curr_byte & value)
        else:
            # Valeur exacte (keyboard / consumer)
            return (prev_byte == value), (curr_byte == value)

    def _open_device(self, vid: int, pid: int) -> None:
        """Ouvre le device sur la bonne interface si hid_interface est spécifié."""
        iface_num = self._profile.get("hid_interface", None)
        path = None
        if iface_num is not None:
            for info in _HidDevice.paths_for(vid, pid):
                if info["interface"] == iface_num:
                    path = info["path"]
                    break
        self._device = _HidDevice()
        self._device.open(vid, pid, path=path)

    def run(self):
        self._running = True
        vid     = self._profile["vendor_id"]
        pid     = self._profile["product_id"]
        btn_map = self._profile["buttons"]   # {action: [byte_idx, value]}

        while self._running:
            try:
                self._open_device(vid, pid)
                self._device.set_nonblocking(True)
                self.device_status.emit("Pédalier connecté")
                prev = [0] * 16

                while self._running:
                    data = self._device.read(16, timeout_ms=100)
                    if not data:
                        time.sleep(0.01)
                        continue
                    raw = list(data) + [0] * max(0, 16 - len(data))
                    for action, binding in btn_map.items():
                        bidx  = binding[0]
                        value = binding[1]
                        if bidx >= len(raw):
                            continue
                        was, now = self._match(prev[bidx], raw[bidx], value)
                        if was != now:
                            if now:
                                self.pedal_pressed.emit(action)
                            else:
                                self.pedal_released.emit(action)
                    prev = raw

            except Exception as e:
                self.device_status.emit(f"Pédalier déconnecté ({e})")
                if self._device:
                    try:
                        self._device.close()
                    except Exception:
                        pass
                    self._device = None
                time.sleep(1.0)  # retry

    def stop(self):
        self._running = False
        if self._device:
            try:
                self._device.close()
            except Exception:
                pass

    @staticmethod
    def list_devices() -> List[Dict]:
        """Retourne les pédaliers Olympus/OM Digital détectés (dédupliqués par VID+PID)."""
        if not HID_AVAILABLE:
            return []
        seen:  set  = set()
        found: List = []
        try:
            for dev in hid.enumerate():
                if dev["vendor_id"] not in KNOWN_VIDS:
                    continue
                key = (dev["vendor_id"], dev["product_id"])
                if key not in seen:
                    seen.add(key)
                    found.append(dev)
        except Exception:
            pass
        return found


# ─── Widget forme d'onde ──────────────────────────────────────────────────────
class WaveformWidget(QWidget):
    seek_requested = Signal(float)  # 0.0–1.0

    def __init__(self, parent=None):
        super().__init__(parent)
        self._samples: Optional["np.ndarray"] = None
        self._position = 0.0   # 0.0–1.0
        self.setMinimumHeight(70)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        self.setCursor(Qt.CursorShape.PointingHandCursor)

    def load_file(self, path: Path):
        if not WAVEFORM_AVAILABLE:
            return
        try:
            data, sr = sf.read(str(path), always_2d=True)
            mono = data.mean(axis=1)
            # Décimer pour l'affichage
            target = 2000
            step = max(1, len(mono) // target)
            self._samples = mono[::step].astype(np.float32)
            # Normaliser
            peak = np.abs(self._samples).max()
            if peak > 0:
                self._samples /= peak
            self.update()
        except Exception:
            self._samples = None
            self.update()

    def clear(self):
        self._samples = None
        self._position = 0.0
        self.update()

    def set_position(self, ratio: float):
        self._position = max(0.0, min(1.0, ratio))
        self.update()

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton and self._samples is not None:
            ratio = event.position().x() / self.width()
            self.seek_requested.emit(ratio)

    def paintEvent(self, event):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        w, h = self.width(), self.height()
        mid = h / 2

        # Fond
        grad = QLinearGradient(0, 0, 0, h)
        grad.setColorAt(0, QColor(COLORS["surface"]))
        grad.setColorAt(1, QColor(COLORS["bg"]))
        p.fillRect(0, 0, w, h, grad)

        if self._samples is None or len(self._samples) == 0:
            # Message "no waveform"
            p.setPen(QColor(COLORS["text_dim"]))
            p.drawText(QRect(0, 0, w, h), Qt.AlignmentFlag.AlignCenter,
                       "Chargez un fichier audio pour afficher la forme d'onde")
            return

        n = len(self._samples)
        pos_x = int(self._position * w)

        # Dessiner les barres de forme d'onde
        bar_w = max(1, w // n)
        if bar_w < 1:
            bar_w = 1

        for i, sample in enumerate(self._samples):
            x = int(i * w / n)
            bar_h = abs(float(sample)) * mid * 0.85
            if x < pos_x:
                color = QColor(COLORS["waveform_pos"])
                color.setAlphaF(0.8)
            else:
                color = QColor(COLORS["waveform"])
                color.setAlphaF(0.5)
            p.fillRect(x, int(mid - bar_h), max(1, bar_w - 1), int(bar_h * 2) + 1, color)

        # Ligne centrale
        p.setPen(QPen(QColor(COLORS["border"]), 1, Qt.PenStyle.DotLine))
        p.drawLine(0, int(mid), w, int(mid))

        # Curseur de position
        p.setPen(QPen(QColor("#FFFFFF"), 2))
        p.drawLine(pos_x, 0, pos_x, h)

        # Bord supérieur
        p.setPen(QPen(QColor(COLORS["border"]), 1))
        p.drawLine(0, 0, w, 0)
        p.drawLine(0, h - 1, w, h - 1)


# ─── Panneau de contrôle audio ────────────────────────────────────────────────
class PlayerPanel(QWidget):
    """Barre de contrôle audio + forme d'onde."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self._player = QMediaPlayer()
        self._audio  = QAudioOutput()
        self._player.setAudioOutput(self._audio)
        self._audio.setVolume(1.0)
        self._rewind_timer = QTimer()
        self._rewind_timer.setInterval(100)
        self._rewind_timer.timeout.connect(self._do_rewind)
        self._forward_timer = QTimer()
        self._forward_timer.setInterval(100)
        self._forward_timer.timeout.connect(self._do_forward)
        self._pedal_connected = False

        self._build_ui()
        self._connect_signals()

    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(8, 6, 8, 6)
        layout.setSpacing(4)

        # ── Forme d'onde ──────────────────────────────────────────────────────
        self.waveform = WaveformWidget()
        layout.addWidget(self.waveform)

        # ── Slider de position ────────────────────────────────────────────────
        pos_row = QHBoxLayout()
        self._lbl_cur = QLabel("00:00")
        self._lbl_cur.setFixedWidth(45)
        self._lbl_cur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;")
        self._lbl_dur = QLabel("00:00")
        self._lbl_dur.setFixedWidth(45)
        self._lbl_dur.setAlignment(Qt.AlignmentFlag.AlignRight)
        self._lbl_dur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;")
        self._seek_slider = QSlider(Qt.Orientation.Horizontal)
        self._seek_slider.setRange(0, 0)
        pos_row.addWidget(self._lbl_cur)
        pos_row.addWidget(self._seek_slider)
        pos_row.addWidget(self._lbl_dur)
        layout.addLayout(pos_row)

        # ── Boutons de transport ──────────────────────────────────────────────
        ctrl_row = QHBoxLayout()
        ctrl_row.setSpacing(8)

        # Volume
        vol_lbl = QLabel("🔊")
        vol_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:14px;")
        self._vol_slider = QSlider(Qt.Orientation.Horizontal)
        self._vol_slider.setRange(0, 100)
        self._vol_slider.setValue(100)
        self._vol_slider.setFixedWidth(80)

        # Boutons transport
        self._btn_rw   = self._mk_btn("⏮",  "Début (Home)",       self.goto_start)
        self._btn_rew  = self._mk_btn("⏪",  f"Reculer {SKIP_MS//1000}s", self.skip_back)
        self._btn_play = self._mk_btn("▶",   "Lecture / Pause (Espace)", self.toggle_play)
        self._btn_fwd  = self._mk_btn("⏩",  f"Avancer {SKIP_MS//1000}s", self.skip_forward)
        self._btn_end  = self._mk_btn("⏭",  "Fin",                 self.goto_end)

        self._btn_play.setObjectName("accent")
        self._btn_play.setFixedSize(44, 44)

        # Vitesse
        spd_lbl = QLabel("Vitesse:")
        spd_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;")
        self._speed_box = QComboBox()
        self._speed_box.addItems(["0.5×", "0.75×", "1.0×", "1.25×", "1.5×", "2.0×"])
        self._speed_box.setCurrentIndex(2)
        self._speed_box.setFixedWidth(75)
        self._speed_box.currentIndexChanged.connect(self._on_speed_changed)

        ctrl_row.addWidget(vol_lbl)
        ctrl_row.addWidget(self._vol_slider)
        ctrl_row.addStretch()
        ctrl_row.addWidget(self._btn_rw)
        ctrl_row.addWidget(self._btn_rew)
        ctrl_row.addWidget(self._btn_play)
        ctrl_row.addWidget(self._btn_fwd)
        ctrl_row.addWidget(self._btn_end)
        ctrl_row.addStretch()
        ctrl_row.addWidget(spd_lbl)
        ctrl_row.addWidget(self._speed_box)

        layout.addLayout(ctrl_row)

        # Indicateur pédalier
        self._pedal_lbl = QLabel("🦶 Pédalier: non connecté")
        self._pedal_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:10px;")
        layout.addWidget(self._pedal_lbl, alignment=Qt.AlignmentFlag.AlignRight)

        # Style fond panneau
        self.setStyleSheet(f"""
            PlayerPanel {{
                background: {COLORS['surface']};
                border-top: 2px solid {COLORS['accent']};
            }}
        """)
        self.setAutoFillBackground(True)

    def apply_theme(self):
        self._lbl_cur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;")
        self._lbl_dur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;")
        self._pedal_lbl.setStyleSheet(
            f"color:{COLORS['success'] if self._pedal_connected else COLORS['text_dim']};font-size:10px;")
        self.setStyleSheet(f"""
            PlayerPanel {{
                background: {COLORS['surface']};
                border-top: 2px solid {COLORS['accent']};
            }}
        """)
        for btn in [self._btn_rw, self._btn_rew, self._btn_play, self._btn_fwd, self._btn_end]:
            btn.setStyleSheet(f"""
                QPushButton {{
                    background: {COLORS['surface2']};
                    border: 1px solid {COLORS['border']};
                    border-radius: 18px;
                    font-size: 14px;
                    color: {COLORS['text']};
                }}
                QPushButton:hover {{ background: {COLORS['accent']}; color: white; border: none; }}
                QPushButton#accent {{
                    background: {COLORS['accent']};
                    color: white;
                    border: none;
                    border-radius: 22px;
                }}
                QPushButton#accent:hover {{ background: {COLORS['accent_hover']}; }}
            """)
        self.waveform.update()

    def _mk_btn(self, icon_txt: str, tooltip: str, slot) -> QPushButton:
        btn = QPushButton(icon_txt)
        btn.setToolTip(tooltip)
        btn.setFixedSize(36, 36)
        btn.clicked.connect(slot)
        btn.setStyleSheet(f"""
            QPushButton {{
                background: {COLORS['surface2']};
                border: 1px solid {COLORS['border']};
                border-radius: 18px;
                font-size: 14px;
                color: {COLORS['text']};
            }}
            QPushButton:hover {{ background: {COLORS['accent']}; color: white; border: none; }}
            QPushButton#accent {{
                background: {COLORS['accent']};
                color: white;
                border: none;
                border-radius: 22px;
            }}
            QPushButton#accent:hover {{ background: {COLORS['accent_hover']}; }}
        """)
        return btn

    def _connect_signals(self):
        self._player.positionChanged.connect(self._on_position_changed)
        self._player.durationChanged.connect(self._on_duration_changed)
        self._player.playbackStateChanged.connect(self._on_state_changed)
        self._seek_slider.sliderMoved.connect(self._on_seek)
        self._vol_slider.valueChanged.connect(self._on_volume_changed)
        self.waveform.seek_requested.connect(self._on_waveform_seek)

    # ── Chargement fichier ────────────────────────────────────────────────────
    def load_file(self, path: Path):
        self._player.stop()
        self._player.setSource(QUrl.fromLocalFile(str(path)))
        self.waveform.load_file(path)
        self._btn_play.setText("▶")

    def unload(self):
        self._player.stop()
        self._player.setSource(QUrl())
        self.waveform.clear()
        self._seek_slider.setRange(0, 0)
        self._lbl_cur.setText("00:00")
        self._lbl_dur.setText("00:00")

    # ── Transport ─────────────────────────────────────────────────────────────
    def toggle_play(self):
        if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            self._player.pause()
        else:
            self._player.play()

    def stop_playback(self):
        self._player.stop()

    def toggle_mute(self):
        self._audio.setMuted(not self._audio.isMuted())

    def set_volume(self, value: int):
        """Slot connecté au slider volume toolbar (0-100)."""
        self._audio.setVolume(value / 100.0)
        # Synchroniser le slider interne du PlayerPanel
        self._vol_slider.blockSignals(True)
        self._vol_slider.setValue(value)
        self._vol_slider.blockSignals(False)

    def volume_up(self):
        v = min(100, self._vol_slider.value() + 10)
        self._vol_slider.setValue(v)

    def volume_down(self):
        v = max(0, self._vol_slider.value() - 10)
        self._vol_slider.setValue(v)

    def speed_up(self):
        i = min(self._speed_box.count() - 1, self._speed_box.currentIndex() + 1)
        self._speed_box.setCurrentIndex(i)

    def speed_down(self):
        i = max(0, self._speed_box.currentIndex() - 1)
        self._speed_box.setCurrentIndex(i)

    def skip_back(self):
        self._player.setPosition(max(0, self._player.position() - SKIP_MS))

    def skip_forward(self):
        dur = self._player.duration()
        self._player.setPosition(min(dur, self._player.position() + SKIP_MS) if dur else 0)

    def goto_start(self):
        self._player.setPosition(0)

    def goto_end(self):
        dur = self._player.duration()
        if dur:
            self._player.setPosition(dur)

    # ── Pédalier ─────────────────────────────────────────────────────────────
    def pedal_pressed(self, action: str):
        if action == "play":
            self.toggle_play()
        elif action == "rewind":
            self._player.pause()
            self._rewind_timer.start()
        elif action == "forward":
            self._forward_timer.start()

    def pedal_released(self, action: str):
        if action == "rewind":
            self._rewind_timer.stop()
            self._player.play()
        elif action == "forward":
            self._forward_timer.stop()

    def _do_rewind(self):
        self._player.setPosition(max(0, self._player.position() - 500))

    def _do_forward(self):
        dur = self._player.duration()
        pos = self._player.position() + 500
        self._player.setPosition(min(dur, pos) if dur else 0)

    def set_pedal_status(self, msg: str):
        connected = msg.lower().startswith("pédalier connecté")
        self._pedal_connected = connected
        color = COLORS["success"] if connected else COLORS["text_dim"]
        self._pedal_lbl.setText(f"🦶 Pédalier: {msg}")
        self._pedal_lbl.setStyleSheet(f"color:{color};font-size:10px;")

    # ── Slots Qt ──────────────────────────────────────────────────────────────
    def _on_position_changed(self, ms: int):
        dur = self._player.duration()
        if dur > 0:
            self._seek_slider.setValue(ms)
            ratio = ms / dur
            self.waveform.set_position(ratio)
        self._lbl_cur.setText(self._ms_to_str(ms))

    def _on_duration_changed(self, ms: int):
        self._seek_slider.setRange(0, ms)
        self._lbl_dur.setText(self._ms_to_str(ms))

    def _on_state_changed(self, state):
        if state == QMediaPlayer.PlaybackState.PlayingState:
            self._btn_play.setText("⏸")
        else:
            self._btn_play.setText("▶")

    def _on_seek(self, value: int):
        self._player.setPosition(value)

    def _on_volume_changed(self, value: int):
        self._audio.setVolume(value / 100.0)
        # Sync toolbar slider si disponible (signal émis depuis PlayerPanel)
        # La synchronisation inverse (toolbar→panel) est gérée par set_volume()

    def _on_waveform_seek(self, ratio: float):
        dur = self._player.duration()
        if dur > 0:
            self._player.setPosition(int(ratio * dur))

    def _on_speed_changed(self, idx: int):
        speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
        self._player.setPlaybackRate(speeds[idx])

    @staticmethod
    def _ms_to_str(ms: int) -> str:
        s = ms // 1000
        m, s = divmod(s, 60)
        h, m = divmod(m, 60)
        if h:
            return f"{h:02d}:{m:02d}:{s:02d}"
        return f"{m:02d}:{s:02d}"


# ─── Table des dictées ────────────────────────────────────────────────────────
COLUMNS = ["", "Nom du fichier", "N° tâche", "Auteur", "Type", "Durée", "Créé", "Statut"]
COL_IDX = {name: i for i, name in enumerate(COLUMNS)}

class DictationTable(QTableWidget):
    file_selected = Signal(object)   # DictationFile

    def __init__(self, parent=None):
        super().__init__(0, len(COLUMNS), parent)
        self._files: List[DictationFile] = []
        self._setup_header()
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.setAlternatingRowColors(True)
        self.verticalHeader().setVisible(False)
        self.setShowGrid(True)
        self.setSortingEnabled(True)
        self.cellDoubleClicked.connect(self._on_double_click)
        self.itemSelectionChanged.connect(self._on_selection)
        self.setStyleSheet(f"""
            QTableWidget {{
                alternate-background-color: {COLORS['surface']};
            }}
        """)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self._context_menu)

    def apply_theme(self):
        self.setStyleSheet(f"""
            QTableWidget {{
                alternate-background-color: {COLORS['surface']};
            }}
        """)
        self.viewport().update()
        self.horizontalHeader().viewport().update()

    def _setup_header(self):
        self.setHorizontalHeaderLabels(COLUMNS)
        hdr = self.horizontalHeader()
        hdr.setStretchLastSection(False)
        hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
        hdr.resizeSection(0, 24)
        hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        for i in range(2, len(COLUMNS)):
            hdr.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
        self.setColumnWidth(0, 24)

    def load_files(self, files: List[DictationFile]):
        self._files = files
        self._refresh()

    def _refresh(self):
        self.setSortingEnabled(False)
        self.setRowCount(0)
        for f in self._files:
            self._add_row(f)
        self.setSortingEnabled(True)

    def _add_row(self, f: DictationFile):
        row = self.rowCount()
        self.insertRow(row)
        self.setRowHeight(row, 32)

        # Police monospace pour les colonnes numériques/temporelles
        mono = QFont()
        mono.setFamilies(["JetBrains Mono", "Fira Mono", "DejaVu Sans Mono",
                          "Liberation Mono", "Courier New", "monospace"])
        mono.setPointSize(11)

        # Indicateur statut coloré
        status_item = QTableWidgetItem()
        status_item.setBackground(QColor(f.status_color))
        status_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
        self.setItem(row, 0, status_item)

        items_data = [
            (1, f.filename,     False),
            (2, str(f.job_number) if f.job_number else "", True),
            (3, f.author,       False),
            (4, f.work_type,    False),
            (5, f.duration_str, True),
            (6, f.created.strftime("%d/%m/%Y %H:%M"), True),
            (7, f.status.value, False),
        ]
        for col, text, use_mono in items_data:
            item = QTableWidgetItem(text)
            item.setData(Qt.ItemDataRole.UserRole, f)
            if use_mono:
                item.setFont(mono)
            if col == 7:
                item.setForeground(QColor(f.status_color))
            self.setItem(row, col, item)

    def _on_double_click(self, row: int, col: int):
        item = self.item(row, 1)
        if item:
            f = item.data(Qt.ItemDataRole.UserRole)
            if f:
                self.file_selected.emit(f)

    def _on_selection(self):
        rows = self.selectionModel().selectedRows()
        if rows:
            item = self.item(rows[0].row(), 1)
            if item:
                f = item.data(Qt.ItemDataRole.UserRole)
                if f:
                    self.file_selected.emit(f)

    def _context_menu(self, pos):
        item = self.item(self.rowAt(pos.y()), 1)
        if not item:
            return
        f: DictationFile = item.data(Qt.ItemDataRole.UserRole)
        if not f:
            return

        menu = QMenu(self)
        menu.setStyleSheet(STYLESHEET)
        act_open    = menu.addAction("▶  Ouvrir / Lire")
        menu.addSeparator()
        # Sous-menu statut
        status_menu = menu.addMenu("Changer le statut")
        acts_status = {}
        for s in DictationStatus:
            a = status_menu.addAction(s.value)
            acts_status[a] = s
        menu.addSeparator()
        act_reveal  = menu.addAction("📂  Révéler dans le dossier")
        act_remove  = menu.addAction("🗑  Supprimer de la liste")

        chosen = menu.exec(self.viewport().mapToGlobal(pos))
        if chosen == act_open:
            self.file_selected.emit(f)
        elif chosen in acts_status:
            f.status = acts_status[chosen]
            self._refresh()
        elif chosen == act_reveal:
            import subprocess
            if sys.platform == "win32":
                subprocess.Popen(["explorer", str(f.path.parent)])
            elif sys.platform == "darwin":
                subprocess.Popen(["open", str(f.path.parent)])
            else:
                subprocess.Popen(["xdg-open", str(f.path.parent)])
        elif chosen == act_remove:
            self._files.remove(f)
            self._refresh()

    def add_file(self, f: DictationFile):
        if f not in self._files:
            self._files.append(f)
            self._add_row(f)

    def get_stats(self) -> Dict[str, int]:
        stats = {s: 0 for s in DictationStatus}
        for f in self._files:
            stats[f.status] += 1
        return stats


# ─── Arbre de dossiers ────────────────────────────────────────────────────────
class FolderTree(QTreeWidget):
    folder_changed = Signal(Path)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setHeaderLabel("Arbre de dictées")
        self.setMinimumWidth(200)
        self.setMaximumWidth(280)
        self._root_path: Optional[Path] = None
        self._build_default()
        self.itemClicked.connect(self._on_clicked)

    def _build_default(self):
        self.clear()
        root = QTreeWidgetItem(self, ["📋  Dictées"])
        root.setExpanded(True)

        todo_item = QTreeWidgetItem(root, ["📥  À faire (0)"])
        todo_item.setData(0, Qt.ItemDataRole.UserRole, "filter:TODO")
        prog_item = QTreeWidgetItem(root, ["🔵  En cours (0)"])
        prog_item.setData(0, Qt.ItemDataRole.UserRole, "filter:IN_PROGRESS")
        done_item = QTreeWidgetItem(root, ["✅  Terminées (0)"])
        done_item.setData(0, Qt.ItemDataRole.UserRole, "filter:DONE")
        susp_item = QTreeWidgetItem(root, ["⏸  Suspendues (0)"])
        susp_item.setData(0, Qt.ItemDataRole.UserRole, "filter:SUSPENDED")

        sep = QTreeWidgetItem(self, [""])
        sep.setFlags(Qt.ItemFlag.NoItemFlags)

        inbox = QTreeWidgetItem(self, ["📨  Répertoires"])
        inbox.setExpanded(True)
        self._folder_root = inbox
        self._status_items = {
            "TODO":        todo_item,
            "IN_PROGRESS": prog_item,
            "DONE":        done_item,
            "SUSPENDED":   susp_item,
        }

    def set_root_folder(self, path: Path):
        self._root_path = path
        # Supprimer anciens enfants dossier
        while self._folder_root.childCount():
            self._folder_root.removeChild(self._folder_root.child(0))

        for sub in sorted(path.iterdir()):
            if sub.is_dir() and not sub.name.startswith("."):
                item = QTreeWidgetItem(self._folder_root, [f"📁  {sub.name}"])
                item.setData(0, Qt.ItemDataRole.UserRole, str(sub))
        self._folder_root.setExpanded(True)

        # Ajouter dossier racine en tête (sans parent pour éviter le double-add)
        root_item = QTreeWidgetItem([f"📂  {path.name} (racine)"])
        root_item.setData(0, Qt.ItemDataRole.UserRole, str(path))
        self._folder_root.insertChild(0, root_item)

    def update_stats(self, stats: Dict):
        labels = {
            "TODO":        "📥  À faire",
            "IN_PROGRESS": "🔵  En cours",
            "DONE":        "✅  Terminées",
            "SUSPENDED":   "⏸  Suspendues",
        }
        for key, item in self._status_items.items():
            count = stats.get(DictationStatus[key], 0)
            item.setText(0, f"{labels[key]} ({count})")

    def _on_clicked(self, item, col):
        data = item.data(0, Qt.ItemDataRole.UserRole)
        if isinstance(data, str) and os.path.isdir(data):
            self.folder_changed.emit(Path(data))


# ─── Dialogue test HID brut ───────────────────────────────────────────────────
class HidTestWorker(QThread):
    """Lit les données HID brutes pour diagnostic."""
    raw_data = Signal(list)   # liste d'entiers (bytes reçus)

    def __init__(self, vid: int, pid: int, parent=None):
        super().__init__(parent)
        self._vid     = vid
        self._pid     = pid
        self._running = False

    def run(self):
        self._running = True
        try:
            dev = _HidDevice()
            dev.open(self._vid, self._pid)
            dev.set_nonblocking(True)
            while self._running:
                data = dev.read(16, timeout_ms=100)
                if data:
                    self.raw_data.emit(list(data))
                else:
                    time.sleep(0.01)
            dev.close()
        except Exception:
            pass

    def stop(self):
        self._running = False


class HidTestDialog(QDialog):
    """Affiche les données HID brutes pour identifier le mapping des boutons."""

    def __init__(self, vid: int, pid: int, parent=None):
        super().__init__(parent)
        self.setWindowTitle(f"Test HID brut — VID={hex(vid)} PID={hex(pid)}")
        self.setMinimumWidth(520)
        self._worker = None

        layout = QVBoxLayout(self)
        layout.addWidget(QLabel(
            "Appuyez sur les pédales une à une.\n"
            "Les octets qui changent indiquent les bits de chaque pédale."))

        self._log = QPlainTextEdit()
        self._log.setReadOnly(True)
        self._log.setMaximumBlockCount(200)
        self._log.setFont(QFont("Monospace", 10))
        layout.addWidget(self._log)

        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        btn_box.rejected.connect(self._stop_and_close)
        layout.addWidget(btn_box)

        if HID_AVAILABLE:
            self._worker = HidTestWorker(vid, pid)
            self._worker.raw_data.connect(self._on_data)
            self._worker.start()
        else:
            self._log.appendPlainText("Module 'hid' non disponible.")

    def _on_data(self, data: list):
        hex_str  = " ".join(f"{b:02X}" for b in data)
        bits_str = " ".join(f"[{i}]={data[i]:08b}" for i in range(min(4, len(data))))
        self._log.appendPlainText(f"Hex: {hex_str}\n     {bits_str}\n")

    def _stop_and_close(self):
        if self._worker:
            self._worker.stop()
            self._worker.wait(1000)
        self.reject()

    def closeEvent(self, event):
        if self._worker:
            self._worker.stop()
            self._worker.wait(1000)
        super().closeEvent(event)


# ─── Capture d'une touche HID ─────────────────────────────────────────────────
class HidCaptureWorker(QThread):
    """
    Teste TOUTES les interfaces HID d'un device en parallèle.
    La première interface qui détecte un appui gagne.
    Détection par valeur exacte du byte (key code) OU bit isolé (bitmask).
    """
    captured       = Signal(int, int, int)  # byte_idx, value, interface_num
    capture_failed = Signal(str)
    raw_received   = Signal(list, int)      # (bytes, interface_num) pour affichage live
    status_update  = Signal(str)

    TIMEOUT_S = 12

    def __init__(self, vid: int, pid: int, parent=None):
        super().__init__(parent)
        self._vid     = vid
        self._pid     = pid
        self._running = False

    def run(self):
        import queue as _queue
        import threading as _threading

        self._running = True

        # Délai de libération du device (FootPedalWorker vient d'être arrêté)
        for _ in range(10):
            if not self._running:
                return
            time.sleep(0.05)

        paths = _HidDevice.paths_for(self._vid, self._pid)
        if not paths:
            self.capture_failed.emit(
                f"Aucune interface trouvée pour VID={hex(self._vid)} PID={hex(self._pid)}\n"
                "Vérifiez les règles udev et la connexion USB.")
            return

        iface_desc = ", ".join(f"iface {p['interface']}" for p in paths)
        self.status_update.emit(f"Appuyez sur la pédale…\n({iface_desc})")

        result_q  = _queue.Queue()
        stop_evt  = _threading.Event()

        def test_path(info):
            path    = info["path"]
            iface   = info["interface"]
            try:
                dev = _HidDevice()
                dev.open(self._vid, self._pid, path=path)
                dev.set_nonblocking(True)
            except Exception:
                return   # interface inaccessible, silencieux

            try:
                # Drainage + baseline (500 ms)
                baseline = [0] * 16
                last_raw = None
                t_drain  = time.time() + 0.5
                while not stop_evt.is_set() and time.time() < t_drain:
                    d = dev.read(16)
                    if d:
                        raw = list(d) + [0] * max(0, 16 - len(d))
                        self.raw_received.emit(raw, iface)
                        last_raw = raw
                    else:
                        time.sleep(0.02)
                if last_raw:
                    baseline = last_raw

                # Attente appui
                deadline = time.time() + self.TIMEOUT_S
                while not stop_evt.is_set() and time.time() < deadline:
                    d = dev.read(16)
                    if not d:
                        time.sleep(0.02)
                        continue
                    raw = list(d) + [0] * max(0, 16 - len(d))
                    self.raw_received.emit(raw, iface)

                    for bidx in range(min(len(raw), len(baseline))):
                        curr = raw[bidx]
                        base = baseline[bidx]
                        if curr != base and curr != 0:
                            # Changement non-nul → c'est un appui
                            stop_evt.set()
                            result_q.put((bidx, curr, iface))
                            dev.close()
                            return
            except Exception:
                pass
            try:
                dev.close()
            except Exception:
                pass

        threads = [_threading.Thread(target=test_path, args=(p,), daemon=True) for p in paths]
        for t in threads:
            t.start()

        try:
            bidx, val, iface = result_q.get(timeout=self.TIMEOUT_S + 2)
            self.captured.emit(bidx, val, iface)
        except Exception:
            stop_evt.set()
            if self._running:
                self.capture_failed.emit(
                    "Délai dépassé — aucune touche détectée.\n\n"
                    "Causes possibles :\n"
                    "• Règle udev non appliquée (rebrancher le pédalier)\n"
                    "• Mauvais VID/PID sélectionné\n"
                    "• Interface non supportée")

    def stop(self):
        self._running = False


class HidCaptureDialog(QDialog):
    """Fenêtre d'attente de capture d'une touche pédalier."""

    def __init__(self, vid: int, pid: int, action_label: str, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Capture de touche")
        self.setModal(True)
        self.setMinimumWidth(420)
        self._vid          = vid
        self._pid          = pid
        self._action_label = action_label
        self._binding: Optional[tuple] = None
        self._iface_num: int = 0
        self._worker: Optional[HidCaptureWorker] = None
        self._countdown = HidCaptureWorker.TIMEOUT_S

        # ── UI ───────────────────────────────────────────────────────────────
        self._lbl_action = QLabel(f"Appuyez sur la pédale :\n{action_label}")
        self._lbl_action.setAlignment(Qt.AlignmentFlag.AlignCenter)
        f = self._lbl_action.font()
        f.setPointSize(12)
        f.setBold(True)
        self._lbl_action.setFont(f)

        self._lbl_bytes = QLabel("—")
        self._lbl_bytes.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._lbl_bytes.setFont(QFont("Monospace", 9))
        self._lbl_bytes.setStyleSheet(f"color:{COLORS['text_dim']};")

        self._lbl_status = QLabel("Initialisation…")
        self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._lbl_status.setWordWrap(True)

        self._lbl_timer = QLabel(f"{self._countdown}s")
        self._lbl_timer.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._lbl_timer.setStyleSheet(f"color:{COLORS['text_dim']};")

        self._btn_retry  = QPushButton("Réessayer")
        self._btn_cancel = QPushButton("Annuler")
        self._btn_retry.setVisible(False)
        self._btn_retry.clicked.connect(self._retry)
        self._btn_cancel.clicked.connect(self._cancel)

        btn_row = QHBoxLayout()
        btn_row.addStretch()
        btn_row.addWidget(self._btn_retry)
        btn_row.addWidget(self._btn_cancel)

        lay = QVBoxLayout(self)
        lay.setSpacing(8)
        lay.addWidget(self._lbl_action)
        lay.addWidget(self._lbl_bytes)
        lay.addWidget(self._lbl_status)
        lay.addWidget(self._lbl_timer)
        lay.addLayout(btn_row)

        # Compte à rebours (1 s)
        self._tick = QTimer(self)
        self._tick.setInterval(1000)
        self._tick.timeout.connect(self._on_tick)

        self._start_worker()

    # ── Worker ───────────────────────────────────────────────────────────────
    def _start_worker(self):
        self._countdown = HidCaptureWorker.TIMEOUT_S
        self._lbl_timer.setText(f"{self._countdown}s")
        self._lbl_status.setText("Établissement de la baseline (repos)…")
        self._lbl_status.setStyleSheet("")
        self._btn_retry.setVisible(False)
        self._btn_cancel.setText("Annuler")

        self._worker = HidCaptureWorker(self._vid, self._pid)
        self._worker.captured.connect(self._on_captured)
        self._worker.capture_failed.connect(self._on_failed)
        self._worker.raw_received.connect(self._on_raw)
        self._worker.status_update.connect(self._on_status)
        self._worker.start()
        self._tick.start()

    def _stop_worker(self):
        self._tick.stop()
        if self._worker and self._worker.isRunning():
            self._worker.stop()
            self._worker.wait(2000)

    # ── Slots ─────────────────────────────────────────────────────────────────
    def _on_tick(self):
        self._countdown -= 1
        if self._countdown > 0:
            self._lbl_timer.setText(f"{self._countdown}s")
            if self._countdown <= HidCaptureWorker.TIMEOUT_S - 1:
                self._lbl_status.setText("Appuyez maintenant sur la pédale…")
        else:
            self._lbl_timer.setText("0s")

    def _on_raw(self, data: list, iface: int):
        self._lbl_bytes.setText(f"[iface {iface}] " + " ".join(f"{b:02X}" for b in data[:8]))

    def _on_status(self, msg: str):
        self._lbl_status.setText(msg)

    def _on_captured(self, bidx: int, val: int, iface: int):
        self._stop_worker()
        self._binding    = (bidx, val)
        self._iface_num  = iface
        mode = "bitmask" if (val > 0 and (val & (val - 1)) == 0) else "valeur"
        self._lbl_status.setText(
            f"Capturé : byte {bidx}, valeur {hex(val)}  ({mode}, interface {iface})")
        self._lbl_status.setStyleSheet(f"color:{COLORS['success']};font-weight:bold;")
        self._lbl_timer.setText("")
        self._btn_cancel.setText("Fermer")
        QTimer.singleShot(800, self.accept)

    def _on_failed(self, msg: str):
        self._stop_worker()
        self._lbl_status.setText(msg)
        self._lbl_status.setStyleSheet(f"color:{COLORS['warning']};")
        self._lbl_timer.setText("")
        self._btn_retry.setVisible(True)
        self._btn_cancel.setText("Fermer")

    def _retry(self):
        self._stop_worker()
        self._lbl_bytes.setText("—")
        self._start_worker()

    def _cancel(self):
        self._stop_worker()
        self.reject()

    def closeEvent(self, event):
        self._stop_worker()
        super().closeEvent(event)

    @property
    def binding(self) -> Optional[tuple]:
        return self._binding

    @property
    def iface_num(self) -> int:
        return self._iface_num


# ─── Dialogue Paramètres ──────────────────────────────────────────────────────
class SettingsDialog(QDialog):

    # Actions pédalier avec leur libellé affiché
    _ACTION_LABELS = [
        ("rewind",  "Rembobinage"),
        ("play",    "Lecture / Pause"),
        ("forward", "Avance rapide"),
    ]

    def __init__(self, settings: AppSettings, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Paramètres – TranscribeStation")
        self.setMinimumWidth(560)
        self._settings          = settings
        self._detected_devices: List[Dict] = []
        self._current_key: Optional[str]   = None
        self._bindings: Dict               = {}
        self._binding_labels: Dict         = {}   # action -> QLabel
        self._hid_interface: Optional[int] = None
        self._build_ui()
        self._refresh_detected()   # peuple le combo + charge le premier profil

    def _build_ui(self):
        layout = QVBoxLayout(self)
        tabs   = QTabWidget()

        # ── Onglet Pédalier ───────────────────────────────────────────────────
        pedal_tab = QWidget()
        tab_lay   = QVBoxLayout(pedal_tab)
        tab_lay.setSpacing(10)

        pl = QFormLayout()
        pl.setSpacing(10)

        self._pedal_enable = QCheckBox("Activer le support pédalier")
        self._pedal_enable.setChecked(bool(self._settings.get("pedal.enabled", True)))
        pl.addRow("Activer :", self._pedal_enable)

        # Sélection du pédalier détecté
        det_row = QHBoxLayout()
        self._detected_combo = QComboBox()
        self._detected_combo.setMinimumWidth(260)
        self._detected_combo.currentIndexChanged.connect(self._on_pedal_selected)
        refresh_btn = QPushButton("🔄")
        refresh_btn.setFixedWidth(36)
        refresh_btn.setToolTip("Actualiser la liste des pédaliers connectés")
        refresh_btn.clicked.connect(self._refresh_detected)
        det_row.addWidget(self._detected_combo, 1)
        det_row.addWidget(refresh_btn)
        pl.addRow("Pédalier détecté :", det_row)

        self._pedal_vid = QLineEdit()
        self._pedal_vid.setReadOnly(True)
        self._pedal_vid.setStyleSheet("color: gray;")
        self._pedal_pid = QLineEdit()
        self._pedal_pid.setReadOnly(True)
        self._pedal_pid.setStyleSheet("color: gray;")

        self._skip_ms = QSpinBox()
        self._skip_ms.setRange(500, 30000)
        self._skip_ms.setSingleStep(500)
        self._skip_ms.setValue(int(self._settings.get("pedal.skip_ms", SKIP_MS)))
        self._skip_ms.setSuffix(" ms")

        pl.addRow("Vendor ID :", self._pedal_vid)
        pl.addRow("Product ID :", self._pedal_pid)
        pl.addRow("Délai rembobinage/avance :", self._skip_ms)

        test_btn = QPushButton("🎛  Données HID brutes")
        test_btn.clicked.connect(self._test_hid)
        pl.addRow(test_btn)

        if not HID_AVAILABLE:
            warn = QLabel("⚠️  Module 'hid' non installé (pip install hid)")
            warn.setStyleSheet(f"color:{COLORS['warning']};")
            pl.addRow(warn)

        tab_lay.addLayout(pl)

        # ── Groupe assignation des touches ────────────────────────────────────
        grp = QGroupBox("Assignation des touches")
        grp_lay = QFormLayout(grp)
        grp_lay.setSpacing(8)

        info = QLabel(
            "Sélectionnez un pédalier détecté puis cliquez «\u00a0Capturer\u00a0» "
            "et appuyez sur la pédale souhaitée.")
        info.setWordWrap(True)
        info.setStyleSheet(f"color:{COLORS['text_dim']};font-style:italic;")
        grp_lay.addRow(info)

        for action, label in self._ACTION_LABELS:
            b    = self._bindings.get(action, [1, 0])
            lbl  = QLabel(self._fmt_binding(b))
            lbl.setStyleSheet("font-family:monospace;")
            cap  = QPushButton("🎯  Capturer")
            cap.setFixedWidth(110)
            cap.clicked.connect(lambda _=False, a=action, l=lbl: self._capture(a, l))
            row_w = QWidget()
            row_l = QHBoxLayout(row_w)
            row_l.setContentsMargins(0, 0, 0, 0)
            row_l.addWidget(lbl, 1)
            row_l.addWidget(cap)
            grp_lay.addRow(f"{label} :", row_w)
            self._binding_labels[action] = lbl

        tab_lay.addWidget(grp)
        tab_lay.addStretch()
        tabs.addTab(pedal_tab, "🦶  Pédalier")

        # ── Onglet Général ────────────────────────────────────────────────────
        gen_tab = QWidget()
        gl = QFormLayout(gen_tab)
        gl.setSpacing(12)

        self._default_author = QLineEdit(self._settings.get("general.author", ""))
        self._auto_play = QCheckBox("Lecture auto à la sélection")
        self._auto_play.setChecked(bool(self._settings.get("general.auto_play", False)))
        self._theme = QComboBox()
        self._theme.addItem("Clair", "light")
        self._theme.addItem("Sombre", "dark")
        idx = max(0, self._theme.findData(self._settings.get("general.theme", "light")))
        self._theme.setCurrentIndex(idx)

        gl.addRow("Auteur par défaut :", self._default_author)
        gl.addRow("Thème :", self._theme)
        gl.addRow(self._auto_play)
        tabs.addTab(gen_tab, "⚙️  Général")

        layout.addWidget(tabs)
        btns = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btns.accepted.connect(self._save)
        btns.rejected.connect(self.reject)
        layout.addWidget(btns)

    @staticmethod
    def _fmt_binding(b: list) -> str:
        if not b or len(b) < 2 or b[1] == 0:
            return "Non configuré"
        val  = b[1]
        mode = "bitmask" if (val > 0 and (val & (val - 1)) == 0) else "valeur"
        return f"byte {b[0]},  {hex(val)}  ({mode})"

    def _refresh_detected(self):
        """Rescanne les pédaliers connectés et met à jour le combo."""
        self._detected_devices = FootPedalWorker.list_devices()
        self._detected_combo.blockSignals(True)
        self._detected_combo.clear()
        for dev in self._detected_devices:
            name = (dev.get("product_string", "") or "").strip() or "Pédalier USB"
            key  = f"{dev['vendor_id']:04x}:{dev['product_id']:04x}"
            self._detected_combo.addItem(f"{name}  —  {key}", userData=key)
        if not self._detected_devices:
            self._detected_combo.addItem("Aucun pédalier détecté")
        self._detected_combo.blockSignals(False)
        # Charger le profil du premier périphérique
        if self._detected_devices:
            self._on_pedal_selected(0)
        else:
            self._current_key = None
            self._pedal_vid.clear()
            self._pedal_pid.clear()

    def _on_pedal_selected(self, index: int):
        """Charge le profil sauvegardé (ou le défaut) pour le pédalier sélectionné."""
        if index < 0 or index >= len(self._detected_devices):
            return
        dev = self._detected_devices[index]
        vid, pid = dev["vendor_id"], dev["product_id"]
        self._current_key = f"{vid:04x}:{pid:04x}"
        self._pedal_vid.setText(f"0x{vid:04x}")
        self._pedal_pid.setText(f"0x{pid:04x}")

        saved_profiles = self._settings.get("pedal.saved_profiles", {}) or {}
        if self._current_key in saved_profiles:
            # Profil personnalisé sauvegardé
            sp = saved_profiles[self._current_key]
            self._bindings      = {a: list(b) for a, b in sp["buttons"].items()}
            self._hid_interface = sp.get("hid_interface")
        else:
            # Chercher dans PEDAL_PROFILES par VID/PID
            matched = None
            for pdata in PEDAL_PROFILES.values():
                if pdata["vendor_id"] == vid and pdata["product_id"] == pid:
                    matched = pdata
                    break
            if matched:
                self._bindings = {a: list(b) for a, b in matched["buttons"].items()}
            else:
                self._bindings = {"rewind": [1, 4], "play": [1, 2], "forward": [1, 8]}
            self._hid_interface = None

        # Mettre à jour les étiquettes de binding
        for action, lbl in self._binding_labels.items():
            lbl.setText(self._fmt_binding(self._bindings.get(action, [1, 0])))

    def _test_hid(self):
        if not self._current_key:
            QMessageBox.warning(self, "Erreur", "Aucun pédalier sélectionné.")
            return
        try:
            vid = int(self._pedal_vid.text(), 16)
            pid = int(self._pedal_pid.text(), 16)
        except ValueError:
            QMessageBox.warning(self, "Erreur", "VID/PID invalides.")
            return
        dlg = HidTestDialog(vid, pid, self)
        dlg.exec()

    def _capture(self, action: str, label: QLabel):
        if not self._current_key:
            QMessageBox.warning(self, "Erreur",
                "Aucun pédalier sélectionné.\nBranchez le pédalier et cliquez 🔄.")
            return
        try:
            vid = int(self._pedal_vid.text(), 16)
            pid = int(self._pedal_pid.text(), 16)
        except ValueError:
            QMessageBox.warning(self, "Erreur", "VID/PID invalides.")
            return
        action_label = dict(self._ACTION_LABELS).get(action, action)
        dlg = HidCaptureDialog(vid, pid, action_label, self)
        if dlg.exec() == QDialog.DialogCode.Accepted and dlg.binding:
            bidx, mask = dlg.binding
            self._bindings[action] = [bidx, mask]
            if dlg.iface_num is not None:
                self._hid_interface = dlg.iface_num
            label.setText(self._fmt_binding([bidx, mask]))

    def _save(self):
        self._settings.set("pedal.enabled", self._pedal_enable.isChecked())
        self._settings.set("pedal.skip_ms", self._skip_ms.value())

        # Sauvegarder le profil du pédalier actuellement sélectionné
        if self._current_key and self._bindings:
            try:
                vid = int(self._pedal_vid.text(), 16)
                pid = int(self._pedal_pid.text(), 16)
            except ValueError:
                vid = pid = 0
            # Déterminer le nom lisible
            dev_name = ""
            for dev in self._detected_devices:
                if (f"{dev['vendor_id']:04x}:{dev['product_id']:04x}") == self._current_key:
                    dev_name = (dev.get("product_string", "") or "").strip()
                    break
            if not dev_name:
                # Chercher dans PEDAL_PROFILES
                for pname, pdata in PEDAL_PROFILES.items():
                    if pdata["vendor_id"] == vid and pdata["product_id"] == pid:
                        dev_name = pname
                        break
            saved_profiles = dict(self._settings.get("pedal.saved_profiles", {}) or {})
            saved_profiles[self._current_key] = {
                "name":          dev_name or self._current_key,
                "vid":           vid,
                "pid":           pid,
                "buttons":       self._bindings,
                "hid_interface": self._hid_interface,
            }
            self._settings.set("pedal.saved_profiles", saved_profiles)

        self._settings.set("general.author",    self._default_author.text())
        self._settings.set("general.theme",     self._theme.currentData())
        self._settings.set("general.auto_play", self._auto_play.isChecked())
        self.accept()


# ─── Fenêtre principale ───────────────────────────────────────────────────────
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self._settings    = AppSettings()
        set_theme(self._settings.get("general.theme", "light"))
        self._current_dir: Optional[Path] = None
        self._pedal_worker: Optional[FootPedalWorker] = None
        self._db_path: Optional[Path] = None
        self._pedal_connected = False

        self.setWindowTitle(APP_NAME)
        self.setMinimumSize(1100, 700)
        self.resize(1280, 800)

        self._build_ui()
        self._build_menus()
        self._build_toolbar()
        # Synchroniser volume : slider player → toolbar
        self.player._vol_slider.valueChanged.connect(
            lambda v: self._tb_vol.setValue(v) if hasattr(self, "_tb_vol") else None
        )
        self._apply_theme()
        self._restore_state()
        self._start_pedal()
        self._update_status_bar()
        self.setWindowIcon(_build_app_icon())

    # ── Construction UI ───────────────────────────────────────────────────────
    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)

        # Splitter horizontal : arbre | table
        splitter = QSplitter(Qt.Orientation.Horizontal)
        splitter.setHandleWidth(1)

        self.folder_tree = FolderTree()
        self.folder_tree.folder_changed.connect(self._load_folder)
        splitter.addWidget(self.folder_tree)

        # Panneau droit : table + player
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(0, 0, 0, 0)
        right_layout.setSpacing(0)

        # En-tête dossier courant
        self._dir_label = QLabel("  Aucun dossier ouvert")
        self._dir_label.setStyleSheet(f"""
            background: {COLORS['surface']};
            color: {COLORS['text_dim']};
            padding: 6px 12px;
            border-bottom: 1px solid {COLORS['border']};
            font-size: 11px;
        """)
        right_layout.addWidget(self._dir_label)

        self.table = DictationTable()
        self.table.file_selected.connect(self._on_file_selected)
        right_layout.addWidget(self.table)

        splitter.addWidget(right_panel)
        splitter.setSizes([220, 860])
        main_layout.addWidget(splitter, 1)

        # Panneau lecteur en bas
        self.player = PlayerPanel()
        self.player.setFixedHeight(180)
        main_layout.addWidget(self.player)

        # Barre de statut
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self._lbl_files  = QLabel()
        self._lbl_pedal  = QLabel()
        self._lbl_format = QLabel()
        for lbl in [self._lbl_files, self._lbl_format, self._lbl_pedal]:
            lbl.setStyleSheet(f"color:{COLORS['text_dim']};padding:0 8px;")
        self.status_bar.addWidget(self._lbl_files)
        self.status_bar.addPermanentWidget(self._lbl_format)
        self.status_bar.addPermanentWidget(self._lbl_pedal)

    def _build_menus(self):
        mb = self.menuBar()

        # ── Icône application devant le menu Fichier ──────────────────────────
        ico_lbl = QLabel()
        ico_lbl.setPixmap(_build_app_icon().pixmap(QSize(20, 20)))
        ico_lbl.setContentsMargins(6, 0, 4, 0)
        ico_lbl.setToolTip(f"{APP_NAME} v{APP_VERSION}")
        mb.setCornerWidget(ico_lbl, Qt.Corner.TopLeftCorner)

        # ── Fichier ───────────────────────────────────────────────────────────
        m_file = mb.addMenu("Fichier")
        self._act_open_folder = QAction("📂  Ouvrir un dossier…", self)
        self._act_open_folder.setShortcut("Ctrl+O")
        self._act_open_folder.triggered.connect(self._open_folder_dialog)
        m_file.addAction(self._act_open_folder)

        self._act_add_files = QAction("➕  Ajouter des fichiers…", self)
        self._act_add_files.setShortcut("Ctrl+Shift+O")
        self._act_add_files.triggered.connect(self._add_files_dialog)
        m_file.addAction(self._act_add_files)

        # ── Sous-menu Récemment ouvert ────────────────────────────────────────
        m_file.addSeparator()
        self._m_recent = m_file.addMenu("🕘  Récemment ouvert")
        self._refresh_recent_menu()

        m_file.addSeparator()
        act_save = QAction("💾  Enregistrer la liste", self)
        act_save.setShortcut("Ctrl+S")
        act_save.triggered.connect(self._save_db)
        m_file.addAction(act_save)

        m_file.addSeparator()
        act_quit = QAction("Quitter", self)
        act_quit.setShortcut("Ctrl+Q")
        act_quit.triggered.connect(self.close)
        m_file.addAction(act_quit)

        # ── Lecture ───────────────────────────────────────────────────────────
        m_play = mb.addMenu("Lecture")
        play_actions = [
            ("▶ / ⏸  Lecture / Pause",       "Space",         self.player.toggle_play),
            ("⏹  Stop",                        "S",             self.player.stop_playback),
            ("⏪  Reculer",                    "Left",          self.player.skip_back),
            ("⏩  Avancer",                    "Right",         self.player.skip_forward),
            ("⏮  Début",                      "Home",          self.player.goto_start),
            ("⏭  Fin",                        "End",           self.player.goto_end),
        ]
        for label, shortcut, slot in play_actions:
            a = QAction(label, self)
            a.setShortcut(shortcut)
            a.triggered.connect(slot)
            m_play.addAction(a)

        m_play.addSeparator()

        vol_actions = [
            ("🔊  Volume +",       "+",         self.player.volume_up),
            ("🔉  Volume -",       "-",         self.player.volume_down),
            ("🔇  Muet / Actif",   "M",         self.player.toggle_mute),
        ]
        for label, shortcut, slot in vol_actions:
            a = QAction(label, self)
            a.setShortcut(shortcut)
            a.triggered.connect(slot)
            m_play.addAction(a)

        m_play.addSeparator()

        speed_actions = [
            ("🐇  Vitesse +",   "]",  self.player.speed_up),
            ("🐢  Vitesse -",   "[",  self.player.speed_down),
        ]
        for label, shortcut, slot in speed_actions:
            a = QAction(label, self)
            a.setShortcut(shortcut)
            a.triggered.connect(slot)
            m_play.addAction(a)

        m_play.addSeparator()

        nav_actions = [
            ("⬆  Fichier précédent",  "Ctrl+Up",   self._prev_file),
            ("⬇  Fichier suivant",    "Ctrl+Down", self._next_file),
        ]
        for label, shortcut, slot in nav_actions:
            a = QAction(label, self)
            a.setShortcut(shortcut)
            a.triggered.connect(slot)
            m_play.addAction(a)

        # ── Périphérique ──────────────────────────────────────────────────────
        m_dev = mb.addMenu("Périphérique")
        act_reconnect = QAction("🔄  Reconnecter le pédalier", self)
        act_reconnect.triggered.connect(self._restart_pedal)
        m_dev.addAction(act_reconnect)

        act_detect = QAction("🔍  Détecter les périphériques", self)
        act_detect.triggered.connect(self._detect_devices)
        m_dev.addAction(act_detect)

        # ── Outils ────────────────────────────────────────────────────────────
        m_tools = mb.addMenu("Outils")
        act_settings = QAction("⚙️  Paramètres…", self)
        act_settings.setShortcut("Ctrl+,")
        act_settings.triggered.connect(self._open_settings)
        m_tools.addAction(act_settings)

        # ── Aide ─────────────────────────────────────────────────────────────
        m_help = mb.addMenu("Aide")
        act_about = QAction("À propos", self)
        act_about.triggered.connect(self._show_about)
        m_help.addAction(act_about)

        udev_label = "📋  Accès pédalier (Windows)" if sys.platform == "win32" else "📋  Règle udev pédalier"
        act_udev = QAction(udev_label, self)
        act_udev.triggered.connect(self._show_udev)
        m_help.addAction(act_udev)

    def _build_toolbar(self):
        tb = QToolBar("Contrôles")
        tb.setMovable(False)
        self.addToolBar(tb)
        self._toolbar_buttons: List[tuple[QPushButton, bool]] = []

        # ── Police emoji couleur explicite ────────────────────────────────────
        emoji_font = QFont()
        emoji_font.setFamilies([
            "Noto Color Emoji", "Segoe UI Emoji",
            "Apple Color Emoji", "Twemoji Mozilla",
            "Noto Emoji", "sans-serif",
        ])
        emoji_font.setPointSize(16)

        # ── Style bouton ──────────────────────────────────────────────────────
        BTN = f"""
            QPushButton {{
                background   : {COLORS['surface2']};
                border       : 1px solid {COLORS['border']};
                border-radius: 6px;
                padding      : 0px;
            }}
            QPushButton:hover   {{ background:{COLORS['accent']};
                                   border-color:{COLORS['accent']}; }}
            QPushButton:pressed {{ background:{COLORS['accent_hover']}; }}
        """
        PLAY = f"""
            QPushButton {{
                background   : {COLORS['accent']};
                border       : 1px solid {COLORS['accent']};
                border-radius: 6px;
                padding      : 0px;
            }}
            QPushButton:hover   {{ background:{COLORS['accent_hover']}; }}
            QPushButton:pressed {{ background:{COLORS['accent_hover']}; }}
        """

        def mk(emoji: str, tip: str, slot,
               accent: bool = False, sz: int = 36) -> QPushButton:
            btn = QPushButton(emoji)
            btn.setFont(emoji_font)
            btn.setToolTip(tip)
            btn.setFixedSize(sz, sz)
            btn.setCursor(Qt.CursorShape.PointingHandCursor)
            btn.setStyleSheet(PLAY if accent else BTN)
            btn.clicked.connect(slot)
            self._toolbar_buttons.append((btn, accent))
            return btn

        # ── Fichiers ──────────────────────────────────────────────────────────
        for emoji, tip, slot in [
            ("📂", "Ouvrir un dossier  Ctrl+O",        self._open_folder_dialog),
            ("➕", "Ajouter des fichiers  Ctrl+Shift+O", self._add_files_dialog),
            ("💾", "Enregistrer  Ctrl+S",               self._save_db),
        ]:
            tb.addWidget(mk(emoji, tip, slot))

        tb.addSeparator()

        # ── Transport ─────────────────────────────────────────────────────────
        for emoji, tip, slot, acc, sz in [
            ("⏮️", "Début  Home",            self.player.goto_start,    False, 36),
            ("⏪️", "Reculer  ←",             self.player.skip_back,     False, 36),
            ("▶️",  "Lecture / Pause  Espace", self.player.toggle_play,   True,  40),
            ("⏩️", "Avancer  →",             self.player.skip_forward,  False, 36),
            ("⏭️", "Fin  End",               self.player.goto_end,      False, 36),
            ("⏹️", "Stop  S",                self.player.stop_playback, False, 36),
        ]:
            tb.addWidget(mk(emoji, tip, slot, accent=acc, sz=sz))

        tb.addSeparator()

        # ── Navigation fichiers ───────────────────────────────────────────────
        for emoji, tip, slot in [
            ("⏫", "Fichier précédent  Ctrl+↑", self._prev_file),
            ("⏬", "Fichier suivant   Ctrl+↓",  self._next_file),
        ]:
            tb.addWidget(mk(emoji, tip, slot))

        tb.addSeparator()

        # ── Volume ────────────────────────────────────────────────────────────
        tb.addWidget(mk("🔇", "Muet  M", self.player.toggle_mute))

        self._tb_vol = QSlider(Qt.Orientation.Horizontal)
        self._tb_vol.setRange(0, 100)
        self._tb_vol.setValue(100)
        self._tb_vol.setFixedWidth(80)
        self._tb_vol.setFixedHeight(20)
        self._tb_vol.setToolTip("Volume  +  /  −")
        self._tb_vol.valueChanged.connect(self.player.set_volume)
        tb.addWidget(self._tb_vol)

        tb.addSeparator()

        # ── Outils ────────────────────────────────────────────────────────────
        for emoji, tip, slot in [
            ("⚙️", "Paramètres  Ctrl+,", self._open_settings),
            ("❓", "À propos",            self._show_about),
        ]:
            tb.addWidget(mk(emoji, tip, slot))

        # ── Indicateur pédalier à droite ──────────────────────────────────────
        spacer = QWidget()
        spacer.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
        tb.addWidget(spacer)

        ped_font = QFont()
        ped_font.setFamilies(["Noto Color Emoji", "Segoe UI Emoji",
                               "DejaVu Sans", "sans-serif"])
        ped_font.setPointSize(11)
        self._tb_pedal_lbl = QLabel("🦶 Pédalier: —")
        self._tb_pedal_lbl.setFont(ped_font)
        self._tb_pedal_lbl.setStyleSheet(
            f"color:{COLORS['text_dim']};padding:0 10px;")
        tb.addWidget(self._tb_pedal_lbl)

    # ── Pédalier ──────────────────────────────────────────────────────────────
    def _start_pedal(self):
        if not HID_AVAILABLE:
            return
        if not bool(self._settings.get("pedal.enabled", True)):
            return

        connected      = FootPedalWorker.list_devices()
        saved_profiles = self._settings.get("pedal.saved_profiles", {}) or {}
        profile        = None

        # 1. Pédalier connecté avec profil personnalisé sauvegardé
        for dev in connected:
            key = f"{dev['vendor_id']:04x}:{dev['product_id']:04x}"
            if key in saved_profiles:
                sp = saved_profiles[key]
                profile = {
                    "vendor_id":  sp["vid"],
                    "product_id": sp["pid"],
                    "buttons":    {a: list(b) for a, b in sp["buttons"].items()},
                }
                if sp.get("hid_interface") is not None:
                    profile["hid_interface"] = int(sp["hid_interface"])
                break

        # 2. Repli : premier pédalier connecté reconnu dans PEDAL_PROFILES
        if profile is None:
            for dev in connected:
                vid, pid = dev["vendor_id"], dev["product_id"]
                for pdata in PEDAL_PROFILES.values():
                    if pdata["vendor_id"] == vid and pdata["product_id"] == pid:
                        profile = pdata.copy()
                        break
                if profile:
                    break

        # 3. Dernier recours : pédalier inconnu, mapping par défaut
        if profile is None and connected:
            dev = connected[0]
            profile = {
                "vendor_id":  dev["vendor_id"],
                "product_id": dev["product_id"],
                "buttons":    {"rewind": [1, 4], "play": [1, 2], "forward": [1, 8]},
            }

        if profile is None:
            self._on_pedal_status("Aucun pédalier détecté")
            return

        if self._pedal_worker:
            self._pedal_worker.stop()
            self._pedal_worker.wait(3000)

        self._on_pedal_status(
            f"Connexion pédalier… VID={hex(profile['vendor_id'])} PID={hex(profile['product_id'])}")
        self._pedal_worker = FootPedalWorker(profile)
        self._pedal_worker.pedal_pressed.connect(self.player.pedal_pressed)
        self._pedal_worker.pedal_released.connect(self.player.pedal_released)
        self._pedal_worker.device_status.connect(self._on_pedal_status)
        self._pedal_worker.start()

    def _restart_pedal(self):
        self._start_pedal()

    def _detect_devices(self):
        devices = FootPedalWorker.list_devices()
        if not devices:
            QMessageBox.information(self, "Détection HID",
                f"Aucun périphérique détecté (VIDs : {', '.join(hex(v) for v in sorted(KNOWN_VIDS))}).\n\n"
                "Vérifiez :\n"
                "• Le câble USB du pédalier\n"
                "• Les règles udev (Aide → Règle udev pédalier)")
        else:
            lines = "\n".join(
                f"  • {d.get('product_string','?')} — VID={hex(d['vendor_id'])} PID={hex(d['product_id'])}"
                for d in devices)
            QMessageBox.information(self, "Détection HID",
                f"{len(devices)} périphérique(s) trouvé(s) :\n\n{lines}")

    @Slot(str)
    def _on_pedal_status(self, msg: str):
        connected = msg.lower().startswith("pédalier connecté")
        self._pedal_connected = connected
        if connected:
            color = COLORS["success"]
        elif msg.lower().startswith("paramètres"):
            color = COLORS["text_dim"]
        else:
            color = COLORS["warning"]
        self._tb_pedal_lbl.setText(f"🦶 {msg}")
        self._tb_pedal_lbl.setStyleSheet(f"color:{color};padding:0 10px;")
        self.player.set_pedal_status(msg)
        self._lbl_pedal.setText(f"🦶 {msg}")
        self._lbl_pedal.setStyleSheet(f"color:{color};padding:0 8px;")

    # ── Gestion fichiers ──────────────────────────────────────────────────────
    def _prev_file(self):
        rows = self.table.rowCount()
        if rows == 0:
            return
        sel = self.table.currentRow()
        target = max(0, sel - 1) if sel > 0 else rows - 1
        self.table.selectRow(target)
        self.table.scrollToItem(self.table.item(target, 1))

    def _next_file(self):
        rows = self.table.rowCount()
        if rows == 0:
            return
        sel = self.table.currentRow()
        target = (sel + 1) % rows
        self.table.selectRow(target)
        self.table.scrollToItem(self.table.item(target, 1))

    def _refresh_recent_menu(self):
        """Reconstruit le sous-menu Récemment ouvert depuis les paramètres."""
        self._m_recent.clear()
        recent = self._settings.get_recent()
        if not recent:
            act = QAction("(aucun)", self)
            act.setEnabled(False)
            self._m_recent.addAction(act)
            return
        for folder in recent:
            label = str(folder)
            # Abréger si le chemin est long
            if len(label) > 60:
                label = "…" + label[-57:]
            act = QAction(f"📁  {label}", self)
            act.setToolTip(str(folder))
            act.triggered.connect(lambda checked, f=folder: self._load_folder(f))
            self._m_recent.addAction(act)
        self._m_recent.addSeparator()
        act_clear = QAction("🗑  Effacer l'historique", self)
        act_clear.triggered.connect(self._clear_recent)
        self._m_recent.addAction(act_clear)

    def _clear_recent(self):
        self._settings.set("recent", [])
        self._refresh_recent_menu()

    def _open_folder_dialog(self):
        path = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de dictées")
        if path:
            self._load_folder(Path(path))

    def _load_folder(self, folder: Path):
        self._current_dir = folder
        self._dir_label.setText(f"  📂  {folder}")
        self.folder_tree.set_root_folder(folder)
        self._db_path = folder / ".transcribe_station.json"

        if self._db_path.exists():
            self._load_db()
        else:
            files: List[DictationFile] = []
            exts = {".wav", ".mp3", ".mp4", ".m4a", ".ogg", ".flac", ".dss", ".ds2"}
            for p in sorted(folder.iterdir()):
                if p.suffix.lower() in exts:
                    f = DictationFile(path=p)
                    f.author = self._settings.get("general.author", "")
                    files.append(f)
            self.table.load_files(files)

        # Mémoriser dans les récents et rafraîchir le menu
        self._settings.add_recent(folder)
        self._refresh_recent_menu()

        self._update_status_bar()
        self.setWindowTitle(f"{APP_NAME}  {APP_VERSION} – {folder.name}")

    def _add_files_dialog(self):
        paths, _ = QFileDialog.getOpenFileNames(
            self, "Ajouter des fichiers audio", "",
            "Fichiers audio (*.wav *.mp3 *.mp4 *.m4a *.ogg *.flac *.dss *.ds2);;Tous (*)")
        for p in paths:
            f = DictationFile(path=Path(p))
            f.author = self._settings.get("general.author", "")
            self.table.add_file(f)   # table._files est la source unique, pas de double append
        self._update_status_bar()

    def _on_file_selected(self, f: DictationFile):
        if not f.path.exists():
            QMessageBox.warning(self, "Fichier introuvable",
                f"Le fichier n'existe plus :\n{f.path}")
            return
        self.player.load_file(f.path)
        self._lbl_format.setText(f"  {f.path.suffix.upper().lstrip('.')}  |  {f.filename}")
        f.status = DictationStatus.IN_PROGRESS
        self.table._refresh()
        self._update_status_bar()
        if bool(self._settings.get("general.auto_play", False)):
            self.player.toggle_play()

    # ── Persistance ───────────────────────────────────────────────────────────
    def _save_db(self):
        if not self._db_path:
            return
        data = [f.to_dict() for f in self.table._files]   # source unique
        try:
            with open(self._db_path, "w", encoding="utf-8") as fh:
                json.dump(data, fh, ensure_ascii=False, indent=2)
            self.status_bar.showMessage("✅  Liste sauvegardée.", 3000)
        except Exception as e:
            QMessageBox.critical(self, "Erreur", f"Sauvegarde impossible :\n{e}")

    def _load_db(self):
        try:
            with open(self._db_path, encoding="utf-8") as fh:
                data = json.load(fh)
            files: List[DictationFile] = []
            for d in data:
                try:
                    f = DictationFile.from_dict(d)
                    if f.path.exists():
                        files.append(f)
                except Exception:
                    pass
            self.table.load_files(files)   # source unique, pas de _all_files
        except Exception as e:
            QMessageBox.warning(self, "Erreur", f"Impossible de lire la liste :\n{e}")

    # ── Statut & état ─────────────────────────────────────────────────────────
    def _update_status_bar(self):
        stats = self.table.get_stats()
        total = sum(stats.values())
        todo  = stats.get(DictationStatus.TODO, 0)
        done  = stats.get(DictationStatus.DONE, 0)
        prog  = stats.get(DictationStatus.IN_PROGRESS, 0)
        self._lbl_files.setText(
            f"  {total} fichier(s)  |  ✅ {done} terminé(s)"
            f"  |  🔵 {prog} en cours  |  📥 {todo} à faire")
        self.folder_tree.update_stats(stats)

    def _restore_state(self):
        x, y, w, h = self._settings.load_geometry()
        self.setGeometry(x, y, w, h)
        # Rouvrir le dernier dossier (en priorité sur les récents)
        recent = self._settings.get_recent()
        if recent:
            self._load_folder(recent[0])

    def _save_state(self):
        geo = self.geometry()
        self._settings.save_geometry(geo.x(), geo.y(), geo.width(), geo.height())
        self._save_db()

    # ── Dialogues ─────────────────────────────────────────────────────────────
    def _open_settings(self):
        # Libérer le device HID pendant toute la durée de SettingsDialog
        # (permet à HidCaptureWorker d'ouvrir l'appareil sans conflit)
        if self._pedal_worker and self._pedal_worker.isRunning():
            self._pedal_worker.stop()
            self._pedal_worker.wait(3000)
        self._on_pedal_status("Paramètres ouverts…")

        old_theme = self._settings.get("general.theme", "light")
        dlg = SettingsDialog(self._settings, self)
        dlg.exec()   # Accepted ou non : on redémarre le worker dans tous les cas

        if self._settings.get("general.theme", "light") != old_theme:
            self._apply_theme()
        self._restart_pedal()

    def _apply_theme(self):
        set_theme(self._settings.get("general.theme", "light"))
        app = QApplication.instance()
        if app:
            app.setStyleSheet(STYLESHEET)

        if hasattr(self, "_dir_label"):
            self._dir_label.setStyleSheet(f"""
                background: {COLORS['surface']};
                color: {COLORS['text_dim']};
                padding: 6px 12px;
                border-bottom: 1px solid {COLORS['border']};
                font-size: 11px;
            """)

        if hasattr(self, "status_bar"):
            for lbl in [self._lbl_files, self._lbl_format, self._lbl_pedal]:
                lbl.setStyleSheet(f"color:{COLORS['text_dim']};padding:0 8px;")

        if hasattr(self, "_toolbar_buttons"):
            btn_style = f"""
                QPushButton {{
                    background   : {COLORS['surface2']};
                    border       : 1px solid {COLORS['border']};
                    border-radius: 6px;
                    padding      : 0px;
                }}
                QPushButton:hover   {{ background:{COLORS['accent']};
                                       border-color:{COLORS['accent']}; }}
                QPushButton:pressed {{ background:{COLORS['accent_hover']}; }}
            """
            play_style = f"""
                QPushButton {{
                    background   : {COLORS['accent']};
                    border       : 1px solid {COLORS['accent']};
                    border-radius: 6px;
                    padding      : 0px;
                }}
                QPushButton:hover   {{ background:{COLORS['accent_hover']}; }}
                QPushButton:pressed {{ background:{COLORS['accent_hover']}; }}
            """
            for btn, accent in self._toolbar_buttons:
                btn.setStyleSheet(play_style if accent else btn_style)

        if hasattr(self, "_tb_pedal_lbl"):
            ped_color = COLORS["success"] if self._pedal_connected else COLORS["text_dim"]
            self._tb_pedal_lbl.setStyleSheet(f"color:{ped_color};padding:0 10px;")

        if hasattr(self, "player"):
            self.player.apply_theme()
        if hasattr(self, "table"):
            self.table.apply_theme()
        if hasattr(self, "folder_tree"):
            self.folder_tree.viewport().update()

    def _show_about(self):
        dlg = QDialog(self)
        dlg.setWindowTitle(f"À propos de {APP_NAME}")
        dlg.setFixedWidth(440)
        dlg.setWindowIcon(_build_app_icon())

        layout = QVBoxLayout(dlg)
        layout.setSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)

        # ── Bandeau header avec icône et titre ────────────────────────────────
        header = QWidget()
        header.setStyleSheet(f"""
            background: qlineargradient(x1:0,y1:0,x2:1,y2:1,
                stop:0 #3A6BD4, stop:1 #1A3B94);
            border-bottom: 2px solid {COLORS['accent']};
        """)
        h_layout = QHBoxLayout(header)
        h_layout.setContentsMargins(20, 18, 20, 18)
        h_layout.setSpacing(16)

        # Icône grande
        icon_lbl = QLabel()
        icon_pix = _build_app_icon().pixmap(QSize(64, 64))
        icon_lbl.setPixmap(icon_pix)
        icon_lbl.setFixedSize(64, 64)

        # Titre + version
        title_col = QVBoxLayout()
        title_col.setSpacing(2)
        lbl_name = QLabel(APP_NAME)
        lbl_name.setStyleSheet(
            "color:white;font-size:22px;font-weight:700;background:transparent;")
        lbl_ver = QLabel(f"Version {APP_VERSION}")
        lbl_ver.setStyleSheet(
            "color:rgba(255,255,255,0.75);font-size:12px;background:transparent;")
        title_col.addWidget(lbl_name)
        title_col.addWidget(lbl_ver)

        h_layout.addWidget(icon_lbl)
        h_layout.addLayout(title_col)
        h_layout.addStretch()
        layout.addWidget(header)

        # ── Corps du dialogue ─────────────────────────────────────────────────
        body = QWidget()
        body.setStyleSheet(f"background:{COLORS['bg']};")
        b_layout = QVBoxLayout(body)
        b_layout.setContentsMargins(24, 18, 24, 6)
        b_layout.setSpacing(10)

        def info_row(label: str, value: str, link: bool = False) -> QHBoxLayout:
            row = QHBoxLayout()
            lbl = QLabel(label)
            lbl.setStyleSheet(
                f"color:{COLORS['text_dim']};font-size:11px;min-width:110px;")
            val = QLabel(f'<a href="{value}" style="color:{COLORS["accent"]}">{value}</a>'
                         if link else value)
            val.setStyleSheet(f"color:{COLORS['text']};font-size:11px;")
            if link:
                val.setOpenExternalLinks(True)
            row.addWidget(lbl)
            row.addWidget(val)
            row.addStretch()
            return row

        # Infos app
        b_layout.addLayout(info_row("Description :",
            "Gestionnaire de transcription audio"))
        b_layout.addLayout(info_row("Pédaliers supportés :",
            "RS27H/N · RS28H/N · RS31H/N"))
        b_layout.addLayout(info_row("Organisation :", APP_AUTHOR))
        b_layout.addLayout(info_row("Auteur :",       "JT-Tools by Johnny"))
        b_layout.addLayout(info_row("Année :", APP_YEAR))
        b_layout.addLayout(info_row("Code source :", APP_URL, link=True))

        # Séparateur
        sep = QWidget()
        sep.setFixedHeight(1)
        sep.setStyleSheet(f"background:{COLORS['border']};")
        b_layout.addSpacing(4)
        b_layout.addWidget(sep)
        b_layout.addSpacing(4)

        # Infos runtime
        py_ver = sys.version.split()[0]
        try:
            from PySide6 import __version__ as qt_ver
        except Exception:
            qt_ver = "—"

        b_layout.addLayout(info_row("Python :", py_ver))
        b_layout.addLayout(info_row("PySide6 :", qt_ver))
        b_layout.addLayout(info_row("NumPy / soundfile :",
            "disponible" if WAVEFORM_AVAILABLE else "non installé"))
        b_layout.addLayout(info_row("Support HID :",
            "disponible" if HID_AVAILABLE else "non installé (pip install hid)"))

        b_layout.addSpacing(8)

        # Bouton Fermer
        close_btn = QPushButton("Fermer")
        close_btn.setObjectName("accent")
        close_btn.setFixedWidth(100)
        close_btn.clicked.connect(dlg.accept)
        b_layout.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
        b_layout.addSpacing(4)

        layout.addWidget(body)
        dlg.exec()

    def _show_udev(self):
        if sys.platform == "win32":
            rule = (
                "══ CAS 1 — Pédalier non détecté ══════════════════════════════\n\n"
                "Windows doit voir le pédalier sous 'Périphériques d'interface\n"
                "utilisateur (HID)' dans le Gestionnaire de périphériques.\n\n"
                "  • Débranchez et rebranchez le câble USB\n"
                "  • Essayez un autre port USB (USB 2.0 de préférence)\n"
                "  • Si le pédalier apparaît sous 'Souris et autres dispositifs\n"
                "    de pointage' → voir Cas 2 ci-dessous\n\n"
                "══ CAS 2 — Les touches déclenchent des clics souris ══════════\n\n"
                "Windows traite le pédalier comme une souris. Il faut remplacer\n"
                "son pilote par le pilote HID générique via Zadig :\n\n"
                "  1. Télécharger Zadig : https://zadig.akeo.ie\n"
                "  2. Options → List All Devices\n"
                "  3. Sélectionner le pédalier dans la liste\n"
                "  4. Choisir le pilote 'HID (Human Interface Device)'\n"
                "     (NE PAS choisir WinUSB ou libusb — incompatibles)\n"
                "  5. Cliquer 'Replace Driver'\n"
                "  6. Débrancher / rebrancher le pédalier\n\n"
                "══ INFORMATION ══════════════════════════════════════════════\n\n"
                "La bibliothèque hidapi.dll est incluse dans l'exécutable.\n"
                "Aucune installation de driver supplémentaire n'est requise\n"
                "si le périphérique est correctement reconnu en HID."
            )
            title = "Accès pédalier – Windows 11"
        else:
            rule = (
                "Créez le fichier suivant :\n\n"
                "/etc/udev/rules.d/99-olympus-pedal.rules\n"
                "──────────────────────────────────────────────────────────\n"
                "# Accès hidraw sans root\n"
                'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="07b4", MODE="0666", GROUP="plugdev"\n'
                'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="33a2", MODE="0666", GROUP="plugdev"\n\n'
                "# Empêcher le kernel de traiter le pédalier comme une souris\n"
                "# (évite que les touches ouvrent un menu contextuel ou déplacent le curseur)\n"
                'SUBSYSTEM=="input", ATTRS{idVendor}=="07b4", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n'
                'SUBSYSTEM=="input", ATTRS{idVendor}=="33a2", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n'
                "──────────────────────────────────────────────────────────\n\n"
                "Rechargez udev :\n"
                "  sudo udevadm control --reload-rules && sudo udevadm trigger\n\n"
                "Ajoutez votre utilisateur au groupe plugdev :\n"
                "  sudo usermod -aG plugdev $USER\n\n"
                "Puis déconnectez/reconnectez le pédalier (ou redémarrez)."
            )
            title = "Règle udev – Pédalier Olympus / OM Digital"
        dlg = QDialog(self)
        dlg.setWindowTitle(title)
        dlg.setMinimumWidth(520)
        layout = QVBoxLayout(dlg)
        txt = QLabel(rule)
        txt.setFont(QFont("Monospace", 10))
        txt.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
        txt.setWordWrap(True)
        layout.addWidget(txt)
        btn = QPushButton("Fermer")
        btn.clicked.connect(dlg.accept)
        layout.addWidget(btn)
        dlg.exec()

    # ── Fermeture ─────────────────────────────────────────────────────────────
    def closeEvent(self, event):
        self._save_state()
        if self._pedal_worker:
            self._pedal_worker.stop()
            self._pedal_worker.wait(2000)
        event.accept()


# ─── Point d'entrée ───────────────────────────────────────────────────────────
def _build_app_icon() -> QIcon:
    """Génère l'icône de l'application avec QPainter (waveform stylisée)."""
    sizes = [16, 32, 48, 64, 128]
    icon = QIcon()
    for sz in sizes:
        pix = QPixmap(sz, sz)
        pix.fill(Qt.GlobalColor.transparent)
        p = QPainter(pix)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)

        # Fond arrondi dégradé bleu → bleu foncé
        grad = QLinearGradient(0, 0, sz, sz)
        grad.setColorAt(0, QColor("#4A7DE8"))
        grad.setColorAt(1, QColor("#2A4DB8"))
        p.setBrush(grad)
        p.setPen(Qt.PenStyle.NoPen)
        radius = sz * 0.18
        p.drawRoundedRect(0, 0, sz, sz, radius, radius)

        # Barres de forme d'onde centrées
        bar_heights = [0.25, 0.45, 0.70, 0.90, 0.65, 1.0, 0.55, 0.80, 0.40, 0.20]
        n = len(bar_heights)
        margin = sz * 0.14
        total_w = sz - 2 * margin
        bar_w = total_w / (n * 1.7)
        gap   = total_w / n
        mid_y = sz / 2

        for i, h in enumerate(bar_heights):
            x = margin + i * gap + (gap - bar_w) / 2
            bh = h * (sz * 0.36)
            # Couleur : blanc semi-transparent, plus lumineux au centre
            alpha = int(180 + 60 * abs(i - n // 2) / (n // 2))
            color = QColor(255, 255, 255, min(255, alpha))
            p.setBrush(color)
            p.drawRoundedRect(
                int(x), int(mid_y - bh),
                max(1, int(bar_w)), int(bh * 2),
                bar_w / 2, bar_w / 2
            )

        p.end()
        icon.addPixmap(pix)
    return icon


def main():
    app = QApplication(sys.argv)
    app.setApplicationName(APP_NAME)
    app.setApplicationVersion(APP_VERSION)
    app.setOrganizationName(APP_AUTHOR)
    settings = AppSettings()
    set_theme(settings.get("general.theme", "light"))
    app.setStyleSheet(STYLESHEET)
    app.setWindowIcon(_build_app_icon())

    win = MainWindow()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
