2901 lines
114 KiB
Python
2901 lines
114 KiB
Python
#!/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()
|