diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5907d7..dc20abc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,35 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versions
## [Non publié]
+## [1.4.0] - 2026-10-28
+
+### Ajouté
+- Lecteur : réglage « Couper le Mix complet à la lecture » (Général, activé par défaut). À
+ l'ouverture d'un clip qui a une piste Jeu ou une piste d'appli, la piste 1 est coupée pour ne
+ rien entendre en double ; elle se réactive avec M. L'état muet/solo/volume est retenu par
+ clip. L'export et la compression gardent la piste 1 tant qu'on ne l'a pas coupée soi-même
+ (GAL-04).
+- Son « clip terminé » à la fin d'un clip avec « après » (La → Mi → La à l'octave), distinct du
+ carillon de l'appui et des sons d'enregistrement, soumis à « Son de confirmation » et exclu des
+ clips en « Tous les sons sauf PalaClips » (RPL-10). La fin d'un enregistrement ne joue plus le
+ carillon de sauvegarde par-dessus son propre son.
+- Réglages → Replay & qualité en trois onglets (Replay, Enregistrement, Commun), chacun avec son
+ estimation de taille ; l'onglet est retenu pendant la session, un point marque l'onglet qui a
+ un changement non appliqué, et le résumé de l'enregistrement sur État ouvre l'onglet
+ Enregistrement (APP-15).
+
+### Modifié
+- État : en fenêtre large, « Derniers clips » et la colonne des pistes et performances défilent
+ chacun de leur côté sous le bandeau ; sous 1000 px, la page défile comme avant. 12 derniers
+ clips au lieu de 5.
+- État : la cellule Source affiche la vraie source (« 2560×1440 · 240 Hz » pour un écran, la
+ taille de la fenêtre capturée pour une fenêtre), mise à jour en direct ; la qualité enregistrée
+ reste dans le résumé de la cellule Replay.
+- Lecteur : pendant le glisser sur la barre de temps ou une piste, le curseur, le temps et la
+ zone jouée suivent la souris à chaque image ; l'image est chargée quand la souris s'arrête
+ (120 ms) et au relâchement, une demande à la fois. Sur un clip 1440p120 HEVC de 3 min :
+ 2 chargements au lieu de 122, curseur redessiné toutes les 17 ms au lieu d'être figé (GAL-03).
+
## [1.3.1] - 2026-10-26
### Ajouté
diff --git a/Cargo.lock b/Cargo.lock
index eb7f20e..2d656f9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3012,7 +3012,7 @@ dependencies = [
[[package]]
name = "palaclips-app"
-version = "1.3.1"
+version = "1.4.0"
dependencies = [
"palaclips-engine",
"serde",
@@ -3033,7 +3033,7 @@ dependencies = [
[[package]]
name = "palaclips-cli"
-version = "1.3.1"
+version = "1.4.0"
dependencies = [
"anyhow",
"clap",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "palaclips-engine"
-version = "1.3.1"
+version = "1.4.0"
dependencies = [
"crossbeam-channel",
"notify",
diff --git a/Cargo.toml b/Cargo.toml
index d6b793c..218fb42 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ default-members = ["crates/engine", "crates/cli"]
exclude = ["third_party"]
[workspace.package]
-version = "1.3.1"
+version = "1.4.0"
edition = "2024"
rust-version = "1.90"
license = "MIT"
diff --git a/README.fr.md b/README.fr.md
index cc98deb..2b19bbd 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -62,7 +62,9 @@ fonctionne avec les anti-triches comme Easy Anti-Cheat.
- **Capture d'un écran ou d'une fenêtre** par Windows Graphics Capture ; une fenêtre est
retrouvée par le nom de son programme après un redémarrage.
- **Galerie de clips** avec un lecteur multipiste : écoute piste par piste (muet, solo,
- volume), découpe, export, copie du fichier pour le coller dans Discord.
+ volume, retenus par clip ; Mix complet coupé à l'ouverture pour ne rien entendre en double),
+ déplacement fluide même sur un long clip HEVC, découpe, export, copie du fichier pour le
+ coller dans Discord.
- **Compression à une taille cible** : 20 Mo pour Discord, 50 ou 500 Mo pour Nitro, ou une taille
libre ; résolution et images par seconde choisies selon la taille, taille finale vérifiée.
Depuis la galerie (un ou plusieurs clips), le lecteur ou par glisser-déposer depuis
@@ -86,7 +88,7 @@ fonctionne avec les anti-triches comme Easy Anti-Cheat.
| Replay & qualité |
|---|
-|  |
+|  |
## Configuration requise
diff --git a/README.md b/README.md
index 2a0781e..9b59b7c 100644
--- a/README.md
+++ b/README.md
@@ -61,8 +61,9 @@ Anti-Cheat.
Without a hardware encoder, **encoding on the processor** in H.264 or AV1.
- **Screen or window capture** through Windows Graphics Capture; a window is found again by
its program name after a restart.
-- **Clip gallery** with a multitrack player: listen track by track (mute, solo, volume), trim,
- export, or copy the file to paste it into Discord.
+- **Clip gallery** with a multitrack player: listen track by track (mute, solo, volume,
+ remembered per clip; Full mix muted on opening so nothing plays twice), smooth scrubbing even
+ on long HEVC clips, trim, export, or copy the file to paste it into Discord.
- **Compress to a target size**: 20 MB for Discord, 50 or 500 MB for Nitro, or any size;
resolution and frame rate follow the size, and the final size is checked. From the gallery (one
or several clips), the player, or by dragging files from File Explorer.
@@ -84,7 +85,7 @@ Anti-Cheat.
| Replay & quality |
|---|
-|  |
+|  |
## Requirements
diff --git a/app/package-lock.json b/app/package-lock.json
index 47bfe2c..7dae4d2 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "palaclips-ui",
- "version": "1.3.1",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "palaclips-ui",
- "version": "1.3.1",
+ "version": "1.4.0",
"dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
diff --git a/app/package.json b/app/package.json
index fd1fd23..a976b19 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "palaclips-ui",
"private": true,
- "version": "1.3.1",
+ "version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/app/src-tauri/src/clips.rs b/app/src-tauri/src/clips.rs
index 91a1084..e8e5de5 100644
--- a/app/src-tauri/src/clips.rs
+++ b/app/src-tauri/src/clips.rs
@@ -5,11 +5,37 @@ use palaclips_engine::library::{
Clip, ClipQuery, Library, TrackCache, TrackFile, copy_file_to_clipboard, move_to_recycle_bin,
track_peaks, watch,
};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::{EngineSlot, i18n, internal_error};
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct TrackMix {
+ pub volume: f32,
+ pub mute: bool,
+ pub solo: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ClipMix {
+ pub tracks: Vec,
+ pub full_mix_chosen: bool,
+}
+
+impl TrackMix {
+ fn bounded(self) -> Self {
+ Self {
+ volume: if self.volume.is_finite() {
+ self.volume.clamp(0.0, 1.0)
+ } else {
+ 1.0
+ },
+ ..self
+ }
+ }
+}
+
pub const LIBRARY_EVENT: &str = "library-changed";
pub const EXPORT_PROGRESS_EVENT: &str = "export-progress";
pub const EXPORT_FINISHED_EVENT: &str = "export-finished";
@@ -180,6 +206,22 @@ impl EngineSlot {
self.with_library(|library| library.games())
}
+ pub fn clip_mix(&self, path: &str) -> Result, Localized> {
+ let path = self.known_clip(path)?;
+ let stored = self.with_library(|library| library.mix(&path))?;
+ Ok(stored.and_then(|json| serde_json::from_str(&json).ok()))
+ }
+
+ pub fn set_clip_mix(&self, path: &str, mix: ClipMix) -> Result<(), Localized> {
+ let path = self.known_clip(path)?;
+ let mix = ClipMix {
+ tracks: mix.tracks.into_iter().map(TrackMix::bounded).collect(),
+ ..mix
+ };
+ let json = serde_json::to_string(&mix).map_err(|_| internal_error())?;
+ self.with_library(|library| library.set_mix(&path, &json))
+ }
+
pub fn set_clip_favorite(&self, path: &str, favorite: bool) -> Result<(), Localized> {
let path = self.known_clip(path)?;
self.with_library(|library| library.set_favorite(&path, favorite))
@@ -516,6 +558,19 @@ fn percent_decode(text: &str) -> Option {
mod tests {
use super::*;
+ #[test]
+ fn a_stored_track_volume_stays_between_silence_and_full() {
+ let mix = |volume| TrackMix {
+ volume,
+ mute: false,
+ solo: true,
+ };
+ assert_eq!(mix(3.0).bounded().volume, 1.0);
+ assert_eq!(mix(-1.0).bounded().volume, 0.0);
+ assert_eq!(mix(f32::NAN).bounded().volume, 1.0);
+ assert!(mix(0.4).bounded().solo);
+ }
+
#[test]
fn ranges_are_clamped_to_the_file_and_to_one_chunk() {
assert_eq!(
diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs
index f9617cc..8dc90ea 100644
--- a/app/src-tauri/src/commands.rs
+++ b/app/src-tauri/src/commands.rs
@@ -12,9 +12,10 @@ use tauri::State;
use tauri::ipc::{Channel, InvokeResponseBody, Response};
use tauri_plugin_autostart::ManagerExt;
+use crate::clips::ClipMix;
use crate::hotkeys::{HotkeyAction, Hotkeys};
use crate::i18n::{self, Language, LanguageSetting};
-use crate::settings::{Feedback, Theme};
+use crate::settings::{Feedback, Player, Theme};
use crate::{AppStatus, EngineSlot, internal_error};
#[tauri::command]
@@ -111,6 +112,16 @@ pub fn set_feedback(slot: State<'_, EngineSlot>, feedback: Feedback) -> Result<(
slot.update(|settings| settings.feedback = feedback)
}
+#[tauri::command]
+pub fn get_player(slot: State<'_, EngineSlot>) -> Player {
+ slot.player()
+}
+
+#[tauri::command]
+pub fn set_player(slot: State<'_, EngineSlot>, player: Player) -> Result<(), Localized> {
+ slot.update(|settings| settings.player = player)
+}
+
#[tauri::command]
pub async fn set_config(
app: tauri::AppHandle,
@@ -322,6 +333,23 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result, Loca
slot.clip_games()
}
+#[tauri::command]
+pub async fn clip_mix(
+ slot: State<'_, EngineSlot>,
+ path: String,
+) -> Result, Localized> {
+ slot.clip_mix(&path)
+}
+
+#[tauri::command]
+pub async fn set_clip_mix(
+ slot: State<'_, EngineSlot>,
+ path: String,
+ mix: ClipMix,
+) -> Result<(), Localized> {
+ slot.set_clip_mix(&path, mix)
+}
+
#[tauri::command]
pub async fn set_clip_favorite(
slot: State<'_, EngineSlot>,
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index c29bb8a..a3b361f 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
use palaclips_engine::EngineConfig;
use palaclips_engine::engine::{Engine, EngineHandle, LevelRate, track_descriptors};
use palaclips_engine::events::{
- CAPTURE_ALERTS, EngineEvent, EngineState, Localized, Severity, TrackDescriptor, TrackKind,
- TrackState,
+ CAPTURE_ALERTS, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode,
+ TrackDescriptor, TrackKind, TrackState,
};
use serde::Serialize;
use tauri::menu::{Menu, MenuItem};
@@ -49,6 +49,7 @@ pub struct CaptureInfo {
pub width: u32,
pub height: u32,
pub fps: u32,
+ pub source_mode: SourceMode,
}
#[derive(Debug, Clone, Serialize)]
@@ -114,6 +115,7 @@ impl AppStatus {
width,
height,
fps,
+ source_mode,
tracks,
} => {
self.clear_capture_alerts();
@@ -122,9 +124,15 @@ impl AppStatus {
width: *width,
height: *height,
fps: *fps,
+ source_mode: *source_mode,
});
self.tracks = tracks.iter().cloned().map(TrackInfo::named).collect();
}
+ EngineEvent::SourceChanged { source_mode } => {
+ if let Some(capture) = &mut self.capture {
+ capture.source_mode = *source_mode;
+ }
+ }
EngineEvent::TracksRenamed { tracks } => {
for (track, renamed) in self.tracks.iter_mut().zip(tracks) {
track.title = renamed.title.clone();
@@ -289,6 +297,13 @@ impl EngineSlot {
.unwrap_or_default()
}
+ fn player(&self) -> settings::Player {
+ self.settings
+ .lock()
+ .map(|settings| settings.player)
+ .unwrap_or_default()
+ }
+
fn state(&self) -> EngineState {
self.status
.lock()
@@ -566,12 +581,18 @@ impl EngineSlot {
EngineEvent::ClipSaved {
path,
duration_secs,
+ origin,
..
} => {
self.library_changed(app, &[std::path::PathBuf::from(path)]);
let feedback = self.feedback();
- if feedback.sound {
- feedback::play(feedback::Sound::Saved);
+ let sound = match origin {
+ SaveOrigin::Replay => Some(feedback::Sound::Saved),
+ SaveOrigin::PostRoll => Some(feedback::Sound::ClipFinished),
+ SaveOrigin::Recording => None,
+ };
+ if let Some(sound) = sound.filter(|_| feedback.sound) {
+ feedback::play(sound);
}
if feedback.toast {
let game = std::path::Path::new(path)
@@ -735,6 +756,8 @@ pub fn run() {
commands::set_autostart,
commands::get_feedback,
commands::set_feedback,
+ commands::get_player,
+ commands::set_player,
commands::replay_arm,
commands::replay_disarm,
commands::replay_save,
@@ -755,6 +778,8 @@ pub fn run() {
commands::record_stop,
commands::list_clips,
commands::clip_games,
+ commands::clip_mix,
+ commands::set_clip_mix,
commands::set_clip_favorite,
commands::rename_clip,
commands::trash_clip,
@@ -1140,6 +1165,11 @@ mod tests {
width: 2560,
height: 1440,
fps: 60,
+ source_mode: SourceMode {
+ width: 2560,
+ height: 1440,
+ refresh_hz: Some(240),
+ },
tracks: vec![
descriptor("Mix complet", TrackKind::Mix),
descriptor("Discord", TrackKind::Application),
@@ -1168,6 +1198,11 @@ mod tests {
width: 1920,
height: 1080,
fps: 60,
+ source_mode: SourceMode {
+ width: 2560,
+ height: 1440,
+ refresh_hz: Some(240),
+ },
tracks: vec![
descriptor("Jeu", TrackKind::Game),
descriptor("Jeu", TrackKind::Game),
@@ -1219,12 +1254,52 @@ mod tests {
width: 2560,
height: 1440,
fps: 240,
+ source_mode: SourceMode {
+ width: 2560,
+ height: 1440,
+ refresh_hz: Some(240),
+ },
tracks: Vec::new(),
});
assert_eq!(status.alerts.len(), 1);
assert_eq!(status.alerts[0].text.code, "hotkey_unavailable");
}
+ #[test]
+ fn the_source_follows_the_screen_or_window_while_capturing() {
+ let mut status = AppStatus::default();
+ let resized = SourceMode {
+ width: 1280,
+ height: 720,
+ refresh_hz: None,
+ };
+ status.apply(&EngineEvent::SourceChanged {
+ source_mode: resized,
+ });
+ assert!(status.capture.is_none());
+ status.apply(&EngineEvent::CaptureStarted {
+ source: Localized::new("source_window"),
+ width: 1920,
+ height: 1080,
+ fps: 60,
+ source_mode: SourceMode {
+ width: 1600,
+ height: 900,
+ refresh_hz: None,
+ },
+ tracks: Vec::new(),
+ });
+ status.apply(&EngineEvent::SourceChanged {
+ source_mode: resized,
+ });
+ let capture = status.capture.expect("capture");
+ assert_eq!(capture.source_mode, resized);
+ assert_eq!(
+ (capture.width, capture.height, capture.fps),
+ (1920, 1080, 60)
+ );
+ }
+
#[test]
fn a_cleared_alert_leaves_the_list() {
let mut status = AppStatus::default();
@@ -1248,6 +1323,7 @@ mod tests {
duration_secs: 12.0,
save_latency_ms: 20,
thumbnail: None,
+ origin: SaveOrigin::Replay,
});
assert!(status.alerts.is_empty());
}
diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs
index eb5aeac..7a8e690 100644
--- a/app/src-tauri/src/settings.rs
+++ b/app/src-tauri/src/settings.rs
@@ -16,6 +16,7 @@ pub struct Settings {
pub schema_version: u32,
pub engine: EngineConfig,
pub feedback: Feedback,
+ pub player: Player,
pub hotkeys: crate::hotkeys::Hotkeys,
pub theme: Theme,
pub language: LanguageSetting,
@@ -37,6 +38,7 @@ impl Default for Settings {
schema_version: SETTINGS_VERSION,
engine: EngineConfig::default(),
feedback: Feedback::default(),
+ player: Player::default(),
hotkeys: crate::hotkeys::Hotkeys::default(),
theme: Theme::default(),
language: LanguageSetting::default(),
@@ -68,6 +70,20 @@ fn forget_default_names(audio: &mut AudioSettings) {
forget(&mut audio.game_label, |names| &names.game);
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Player {
+ pub mute_full_mix: bool,
+}
+
+impl Default for Player {
+ fn default() -> Self {
+ Self {
+ mute_full_mix: true,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Feedback {
@@ -306,6 +322,7 @@ mod tests {
std::fs::write(&path, SETTINGS_1_2).expect("write");
let loaded = load(&path);
assert!(loaded.problem.is_none());
+ assert!(loaded.settings.player.mute_full_mix);
let engine = &loaded.settings.engine;
assert_eq!(engine.record.video, None);
assert!(!engine.records_separately());
diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json
index ad14a58..5a95968 100644
--- a/app/src-tauri/tauri.conf.json
+++ b/app/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "PalaClips",
- "version": "1.3.1",
+ "version": "1.4.0",
"identifier": "com.palawi.palaclips",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/app/src/App.tsx b/app/src/App.tsx
index f66efed..cf06d1b 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -15,9 +15,10 @@ import { QualityButton, StatusBanner } from "./components/StatusBanner.tsx";
import { type DraftRequest, addDrafts } from "./lib/compressDrafts.ts";
import { useT } from "./lib/i18n.ts";
import { onboardingNeeded } from "./lib/ipc.ts";
+import { type QualityTab, rememberTab } from "./lib/qualityTabs.ts";
import { useEngine } from "./lib/useEngine.ts";
-export type Destination = { view: View; section?: SectionId; focus?: boolean };
+export type Destination = { view: View; section?: SectionId; focus?: boolean; tab?: QualityTab };
export default function App() {
return (
@@ -53,8 +54,12 @@ function Shell() {
[engine.config],
);
- const open = ({ view: nextView, section: nextSection, focus }: Destination) => {
- const focusQuality = () => focus && setQualityFocus((count) => count + 1);
+ const open = ({ view: nextView, section: nextSection, focus, tab }: Destination) => {
+ const focusQuality = () => {
+ if (!focus) return;
+ if (tab) rememberTab(tab);
+ setQualityFocus((count) => count + 1);
+ };
if (nextView === view && (!nextSection || nextSection === section)) {
focusQuality();
return;
@@ -92,8 +97,8 @@ function Shell() {
)}
-
-
+
+
+
{t("clips.title")}
diff --git a/app/src/components/StatusBanner.tsx b/app/src/components/StatusBanner.tsx
index 4e8fdb1..442d2d9 100644
--- a/app/src/components/StatusBanner.tsx
+++ b/app/src/components/StatusBanner.tsx
@@ -2,7 +2,7 @@ import { ChevronRight, Gauge, HardDrive, Monitor, Save } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import type { Destination } from "../App.tsx";
import { recordingConfig } from "../lib/config.ts";
-import type { CaptureTarget, ReplayEstimate } from "../lib/ipc.ts";
+import type { CaptureTarget, ReplayEstimate, SourceMode } from "../lib/ipc.ts";
import { useEstimate } from "../lib/useEstimate.ts";
import { bytes, clock } from "../lib/format.ts";
import { message, t, useT } from "../lib/i18n.ts";
@@ -33,6 +33,11 @@ function targetLabel(target: CaptureTarget | undefined): string {
return target.exe_name;
}
+function sourceText({ width, height, refresh_hz }: SourceMode): string {
+ const size = `${width}×${height}`;
+ return refresh_hz ? `${size} · ${t("units.hz", { value: refresh_hz })}` : size;
+}
+
function ReplayWitness({ phase }: { phase: ReplayPhase }) {
const armed = phase === "armed";
return (
@@ -45,7 +50,8 @@ function ReplayWitness({ phase }: { phase: ReplayPhase }) {
);
}
-const QUALITY: Destination = { view: "settings", section: "replay", focus: true };
+const QUALITY: Destination = { view: "settings", section: "replay", focus: true, tab: "replay" };
+const RECORDING_QUALITY: Destination = { ...QUALITY, tab: "recording" };
export function QualityButton({ onOpen }: { onOpen: (destination: Destination) => void }) {
const t = useT();
@@ -61,13 +67,21 @@ export function QualityButton({ onOpen }: { onOpen: (destination: Destination) =
);
}
-function QualityLink({ summary, onOpen }: { summary: string; onOpen: (destination: Destination) => void }) {
+function QualityLink({
+ summary,
+ destination,
+ onOpen,
+}: {
+ summary: string;
+ destination: Destination;
+ onOpen: (destination: Destination) => void;
+}) {
const t = useT();
const cut = summary.lastIndexOf(" ") + 1;
return (
onOpen(QUALITY)}
+ onClick={() => onOpen(destination)}
aria-label={t("status.qualityLink", { summary })}
className="pressable group -mx-1.5 self-start rounded-md px-1.5 py-0.5 text-left text-ink-2 hover:bg-surface-hover hover:text-ink"
>
@@ -158,7 +172,7 @@ export function StatusBanner({ engine, onOpen }: { engine: Engine; onOpen: (dest
{t("status.inRam")}
- {replaySummary &&
}
+ {replaySummary &&
}
{clipEndsAt !== null &&
}
{bytes(status?.recording_bytes ?? 0)}
)}
- {recordSummary && }
+ {recordSummary && }
- {capture ? `${capture.width}×${capture.height} · ${t("units.fps", { value: capture.fps })}` : t("status.captureStopped")}
+ {capture ? sourceText(capture.source_mode) : t("status.captureStopped")}
{fellBack && {t("status.fellBack")}
}
track.solo);
- return mix.map((track) => !track.mute && track.volume > 0 && (!solo || track.solo));
-}
-
export function Player({
clip,
labels,
@@ -68,12 +69,37 @@ export function Player({
const audio = useRef(null);
const timeText = useRef(null);
const position = useRef(null);
+ const paintAt = useRef<(seconds?: number) => void>(() => undefined);
+ const [scrub] = useState(
+ () =>
+ new ScrubSession({
+ playing: () => video.current?.paused === false,
+ hold: () => {
+ audio.current?.hold();
+ video.current?.pause();
+ },
+ paint: (seconds) => paintAt.current(seconds),
+ seek: (seconds) => {
+ const element = video.current;
+ if (!element || element.readyState < HTMLMediaElement.HAVE_METADATA) return false;
+ element.currentTime = Math.min(Math.max(0, seconds), element.duration);
+ return true;
+ },
+ release: (resume) => {
+ audio.current?.release();
+ paintAt.current();
+ if (resume) video.current?.play().catch((failure) => setError(errorText(failure)));
+ },
+ }),
+ );
const [duration, setDuration] = useState(clip.duration_secs);
const [playing, setPlaying] = useState(false);
const [range, setRange] = useState({ start: 0, end: clip.duration_secs });
const [tracks, setTracks] = useState(null);
const [tracksFailed, setTracksFailed] = useState(null);
- const [mix, setMix] = useState([]);
+ const [mixState, setMixState] = useState({ tracks: [], full_mix_chosen: false });
+ const [muteFullMix, setMuteFullMix] = useState(false);
+ const pendingMix = useRef<{ timer: number; state: ClipMix } | null>(null);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const fps = clip.fps;
@@ -82,10 +108,15 @@ export function Player({
const prepare = useCallback(() => {
setTracksFailed(null);
setTracks(null);
- preparePlayer(clip.path).then(
- (files) => {
+ Promise.all([
+ preparePlayer(clip.path),
+ clipMix(clip.path).catch(() => null),
+ getPlayer().catch(() => ({ mute_full_mix: true })),
+ ]).then(
+ ([files, stored, player]) => {
setTracks(files);
- setMix(files.map(() => FULL_MIX));
+ setMixState(openingMix(files.length, stored));
+ setMuteFullMix(player.mute_full_mix);
audio.current?.load(files.map((file) => file.path));
},
(failure) => setTracksFailed(errorText(failure)),
@@ -100,14 +131,22 @@ export function Player({
audio.current = player;
prepare();
return () => {
+ scrub.dispose();
player.dispose();
audio.current = null;
};
- }, [prepare]);
+ }, [prepare, scrub]);
useEffect(() => {
- audio.current?.mix(mix);
- }, [mix]);
+ const path = clip.path;
+ return () => {
+ const pending = pendingMix.current;
+ if (!pending) return;
+ window.clearTimeout(pending.timer);
+ pendingMix.current = null;
+ setClipMix(path, pending.state).catch(() => undefined);
+ };
+ }, [clip.path]);
useEffect(() => {
if (!notice) return;
@@ -120,9 +159,9 @@ export function Player({
const container = root.current;
if (!element || !container) return;
let frameRequest = 0;
- const paint = () => {
+ const paint = (seconds?: number) => {
const span = element.duration || duration || 1;
- const now = element.currentTime;
+ const now = seconds ?? (scrub.scrubbing ? scrub.position : element.currentTime);
const played = `${Math.min(1, now / span) * 100}%`;
for (const head of container.querySelectorAll("[data-playhead]")) head.style.left = played;
for (const bar of container.querySelectorAll("[data-played]")) bar.style.width = played;
@@ -131,6 +170,11 @@ export function Player({
position.current?.setAttribute("aria-valuenow", String(Math.round(now)));
position.current?.setAttribute("aria-valuetext", preciseTime(now, fps));
};
+ paintAt.current = paint;
+ const seeked = () => {
+ scrub.loaded();
+ paint();
+ };
const loop = () => {
paint();
frameRequest = requestAnimationFrame(loop);
@@ -155,7 +199,7 @@ export function Player({
element.addEventListener("play", start);
element.addEventListener("pause", stop);
element.addEventListener("ended", stop);
- element.addEventListener("seeked", paint);
+ element.addEventListener("seeked", seeked);
element.addEventListener("loadedmetadata", metadata);
paint();
return () => {
@@ -163,10 +207,10 @@ export function Player({
element.removeEventListener("play", start);
element.removeEventListener("pause", stop);
element.removeEventListener("ended", stop);
- element.removeEventListener("seeked", paint);
+ element.removeEventListener("seeked", seeked);
element.removeEventListener("loadedmetadata", metadata);
};
- }, [duration, fps, tracks]);
+ }, [duration, fps, tracks, scrub]);
const seek = (seconds: number) => {
const element = video.current;
@@ -216,7 +260,23 @@ export function Player({
const titles = (tracks ?? []).map((track, index) => track.title || t("player.untitled", { number: index + 1 }));
const kinds: TrackKind[] = titles.map((title) => kindOfTitle(title, labels));
const colors = titles.map((title, index) => trackColor(kinds[index] ?? "application", title, appOrder));
+ const kindsKey = kinds.join();
+ const mix = useMemo(() => heardMix(kinds, mixState, muteFullMix), [kindsKey, mixState, muteFullMix]);
const audible = audibleTracks(mix);
+ const changeMix = (index: number, next: TrackMix) => {
+ const state = changeTrack(kinds, mixState, muteFullMix, index, next);
+ setMixState(state);
+ if (pendingMix.current) window.clearTimeout(pendingMix.current.timer);
+ const timer = window.setTimeout(() => {
+ pendingMix.current = null;
+ setClipMix(clip.path, state).catch((failure) => setError(errorText(failure)));
+ }, MIX_SAVE_MS);
+ pendingMix.current = { timer, state };
+ };
+
+ useEffect(() => {
+ audio.current?.mix(mix);
+ }, [mix]);
return (
@@ -305,7 +365,7 @@ export function Player({
{preciseTime(0, fps)}
-
+
{clock(duration)}
@@ -335,8 +395,8 @@ export function Player({
range={range}
mix={mix[index] ?? FULL_MIX}
audible={audible[index] ?? true}
- onMix={(next) => setMix((current) => current.map((item, place) => (place === index ? next : item)))}
- onSeek={seek}
+ onMix={(next) => changeMix(index, next)}
+ scrub={scrub}
/>
))}
@@ -348,8 +408,8 @@ export function Player({
titles={titles}
kinds={kinds}
colors={colors}
- audible={audible}
- volumes={mix.map((track) => track.volume)}
+ audible={audibleTracks(mixState.tracks)}
+ volumes={mixState.tracks.map((track) => track.volume)}
range={range}
onCompress={onCompress}
/>
diff --git a/app/src/components/gallery/Timeline.tsx b/app/src/components/gallery/Timeline.tsx
index 3c3fc02..c86750e 100644
--- a/app/src/components/gallery/Timeline.tsx
+++ b/app/src/components/gallery/Timeline.tsx
@@ -1,7 +1,7 @@
import { type KeyboardEvent, type PointerEvent, type RefObject, useRef } from "react";
import { preciseTime } from "../../lib/gallery.ts";
import { useT } from "../../lib/i18n.ts";
-import { scrubTime } from "../../lib/scrub.ts";
+import { type Scrubber, scrubTime } from "../../lib/scrub.ts";
export interface Range {
start: number;
@@ -18,6 +18,7 @@ export function Timeline({
fps,
range,
position,
+ scrub,
onSeek,
onRange,
}: {
@@ -25,6 +26,7 @@ export function Timeline({
fps: number;
range: Range;
position: RefObject
;
+ scrub: Scrubber;
onSeek: (seconds: number) => void;
onRange: (range: Range) => void;
}) {
@@ -33,26 +35,30 @@ export function Timeline({
const drag = useRef(null);
const span = Math.max(duration, 0.001);
const frame = 1 / (fps > 0 ? fps : 60);
- const move = (kind: "start" | "end", seconds: number) => {
+ const move = (kind: "start" | "end", seconds: number, seekTo: (seconds: number) => void) => {
if (kind === "start") {
const start = Math.min(seconds, range.end - MIN_RANGE_SECS);
onRange({ start, end: range.end });
- onSeek(start);
+ seekTo(start);
} else onRange({ start: range.start, end: Math.max(seconds, range.start + MIN_RANGE_SECS) });
};
- const apply = (kind: Drag, event: PointerEvent) => {
+ const apply = (kind: Drag, event: PointerEvent, seekTo: (seconds: number) => void) => {
const rect = track.current?.getBoundingClientRect();
if (!rect) return;
const x = event.clientX - rect.left;
- if (kind === "seek") onSeek(scrubTime(x, rect.width, span, [range.start, range.end]));
- else move(kind, scrubTime(x, rect.width, span, []));
+ if (kind === "seek") seekTo(scrubTime(x, rect.width, span, [range.start, range.end]));
+ else move(kind, scrubTime(x, rect.width, span, []), seekTo);
};
const down = (kind: Drag) => (event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
drag.current = kind;
track.current?.setPointerCapture(event.pointerId);
- apply(kind, event);
+ apply(kind, event, (seconds) => scrub.start(seconds));
+ };
+ const release = () => {
+ if (drag.current !== "end") scrub.end();
+ drag.current = null;
};
const nudge = (kind: "start" | "end") => (event: KeyboardEvent) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
@@ -60,7 +66,7 @@ export function Timeline({
event.preventDefault();
event.stopPropagation();
const amount = event.shiftKey ? SECOND_STEPS : frame;
- move(kind, Math.min(span, Math.max(0, (kind === "start" ? range.start : range.end) + step * amount)));
+ move(kind, Math.min(span, Math.max(0, (kind === "start" ? range.start : range.end) + step * amount)), onSeek);
};
const percent = (seconds: number) => `${(seconds / span) * 100}%`;
@@ -68,13 +74,9 @@ export function Timeline({
drag.current && apply(drag.current, event)}
- onPointerUp={() => {
- drag.current = null;
- }}
- onPointerCancel={() => {
- drag.current = null;
- }}
+ onPointerMove={(event) => drag.current && apply(drag.current, event, (seconds) => scrub.move(seconds))}
+ onPointerUp={release}
+ onPointerCancel={release}
className="timeline relative h-10 flex-1 cursor-pointer touch-none select-none"
>
void;
+ scrub: Scrubber;
}) {
const canvas = useRef
(null);
const peaks = useRef(null);
@@ -78,7 +78,7 @@ function Waveform({
const span = Math.max(duration, 0.001);
return (
@@ -103,7 +103,7 @@ export function TrackLane({
mix,
audible,
onMix,
- onSeek,
+ scrub,
}: {
index: number;
title: string;
@@ -114,7 +114,7 @@ export function TrackLane({
mix: TrackMix;
audible: boolean;
onMix: (mix: TrackMix) => void;
- onSeek: (seconds: number) => void;
+ scrub: Scrubber;
}) {
const t = useT();
const idle = "border-line-strong text-ink-2 hover:bg-surface-hover hover:text-ink";
@@ -135,7 +135,7 @@ export function TrackLane({
{title}
-
+
(null);
const config = useLiveConfig();
const [feedback, setFeedbackState] = useState(null);
+ const [muteFullMix, setMuteFullMix] = useState(null);
const [theme, setThemeState] = useState("system");
const [language, setLanguageState] = useState("system");
const [error, setError] = useState(null);
@@ -175,6 +178,7 @@ export function GeneralSection() {
const load = () => {
getAutostart().then(setAutostartState, (failure) => setError(errorText(failure)));
getFeedback().then(setFeedbackState, (failure) => setError(errorText(failure)));
+ getPlayer().then((player) => setMuteFullMix(player.mute_full_mix), (failure) => setError(errorText(failure)));
getTheme().then(setThemeState, () => setThemeState("system"));
getLanguage().then(
(view) => {
@@ -211,6 +215,11 @@ export function GeneralSection() {
run(setFeedback(next), () => setFeedbackState(previous));
};
+ const changeMuteFullMix = (mute_full_mix: boolean) => {
+ setMuteFullMix(mute_full_mix);
+ run(setPlayer({ mute_full_mix }), () => setMuteFullMix(!mute_full_mix));
+ };
+
const changeTheme = (next: Theme) => {
const previous = theme;
setThemeState(next);
@@ -279,6 +288,11 @@ export function GeneralSection() {
/>
)}
+
+ {muteFullMix !== null && (
+
+ )}
+
;
-function PartTitle({ id, children }: { id: string; children: string }) {
- return (
-
- {children}
-
- );
-}
+const TAB_LABELS: Record = {
+ replay: "settings.replay.replayPart",
+ recording: "settings.replay.recordingPart",
+ common: "settings.replay.commonPart",
+};
function VideoRows({
video,
@@ -226,6 +225,9 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
const [encoders, setEncoders] = useState([]);
const [memory, setMemory] = useState(null);
const estimateRef = useRef(null);
+ const tabRefs = useRef>>({});
+ const [tab, setTab] = useState(rememberedTab);
+ const [arrival, setArrival] = useState(0);
const [arrived, setArrived] = useState(false);
const beforeCpu = useRef | null>(null);
const draft = config?.draft ?? null;
@@ -242,11 +244,18 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
useEffect(() => {
if (focusKey <= handledFocus || !loaded) return;
handledFocus = focusKey;
+ setTab(rememberedTab());
+ setArrival((count) => count + 1);
+ }, [focusKey, loaded]);
+
+ useEffect(() => {
+ if (arrival === 0) return;
estimateRef.current?.scrollIntoView({ block: "start" });
estimateRef.current?.focus({ preventScroll: true });
setArrived(true);
- window.setTimeout(() => setArrived(false), ARRIVAL_FLASH_MS);
- }, [focusKey, loaded]);
+ const timer = window.setTimeout(() => setArrived(false), ARRIVAL_FLASH_MS);
+ return () => window.clearTimeout(timer);
+ }, [arrival]);
if (!config || !draft) return ;
const change = config.change;
@@ -317,204 +326,274 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
const perMinute = estimate?.file_bytes_per_minute ?? 0;
const clipBytes = (seconds: number) => (perMinute * seconds) / 60;
const recordPerMinute = recordEstimate?.file_bytes_per_minute ?? 0;
+ const changed = changedTabs(config.saved, draft);
+ const select = (next: QualityTab) => {
+ rememberTab(next);
+ setTab(next);
+ };
+ const moveTab = (event: KeyboardEvent) => {
+ const next = nextTab(tab, event.key);
+ if (!next) return;
+ event.preventDefault();
+ select(next);
+ tabRefs.current[next]?.focus();
+ };
+ const estimateProps = {
+ ref: estimateRef,
+ id: "replay-quality",
+ tabIndex: -1,
+ "data-arrived": arrived,
+ "aria-label": t("settings.replay.estimate"),
+ className: "quality-estimate scroll-mt-4 border-b border-line px-5 py-4",
+ };
+ const perMinuteLine = (label: string, size: number | null) => (
+
+ {label}{" "}
+ {size === null ? "—" : t("settings.replay.approx", { size: bytes(size) })} {" "}
+ {t("settings.replay.perMinute")}
+
+ );
return (
<>
-
-
-
- {estimate ? t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs)) }) : "—"}
-
-
- {t("settings.replay.perClip", { duration: durationLabel(draft.replay.duration_secs) })}
-
- {estimate && (
-
- {"· "}
- {t("settings.replay.approx", { size: bytes(perMinute) })} {" "}
- {t("settings.replay.perMinute")}
-
- )}
-
- {estimate && (
- <>
-
- {technical(estimate, draft.video.fps)}
- {encoderBytes > 0 && <> · {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}>}
-
- {draft.replay.after_secs > 0 && (
-
- {t("settings.replay.withAfter", { after: durationLabel(draft.replay.after_secs) })}{" "}
-
- {t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs + draft.replay.after_secs)) })}
-
-
- )}
-
- {t("settings.replay.bufferSize")}{" "}
- {bytes(estimate.bytes)} {" "}
- {draft.replay.storage === "ram" ? t("settings.replay.inRam") : t("settings.replay.onDisk")}
-
- >
- )}
- {tooMuchRam && memory && (
-
-
- {t("settings.replay.ramAlert", {
- percent: Math.round(ramShare * 100),
- free: bytes(memory.available_bytes),
- })}
-
+ {QUALITY_TABS.map((id) => {
+ const selected = id === tab;
+ return (
{
+ tabRefs.current[id] = element;
+ }}
type="button"
- onClick={() => replay({ storage: "disk" })}
- className="pressable h-9 rounded-md border border-line-strong px-3.5 font-medium text-ink hover:bg-surface-hover"
+ role="tab"
+ id={`quality-tab-${id}`}
+ aria-selected={selected}
+ aria-controls={`quality-panel-${id}`}
+ tabIndex={selected ? 0 : -1}
+ onClick={() => select(id)}
+ className={`pressable relative inline-flex h-11 items-center gap-2 rounded-t-md px-3.5 text-[15px] ${
+ selected ? "font-semibold text-ink" : "text-ink-2 hover:bg-surface-hover hover:text-ink"
+ }`}
>
- {t("settings.replay.toDisk")}
+ {t(TAB_LABELS[id])}
+ {selected && }
+ {changed.includes(id) && (
+
+ {t("settings.replay.unapplied")}
+
+ )}
-
- )}
-
+ );
+ })}
+
-
- {t("settings.replay.replayPart")}
-
- ({ value: seconds, label: durationLabel(seconds) }))}
- onChange={(duration_secs) => replay({ duration_secs })}
- />
-
-
-
-
({
- value: seconds,
- label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds),
- }))}
- onChange={(after_secs) => replay({ after_secs })}
- className="min-w-28"
- />
- replay({ after_secs })}
+
+ {tab === "replay" && (
+ <>
+
+
+
+ {estimate ? t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs)) }) : "—"}
+
+
+ {t("settings.replay.perClip", { duration: durationLabel(draft.replay.duration_secs) })}
+
+ {estimate && (
+
+ {"· "}
+ {t("settings.replay.approx", { size: bytes(perMinute) })} {" "}
+ {t("settings.replay.perMinute")}
+
+ )}
+
+ {estimate && (
+ <>
+
+ {technical(estimate, draft.video.fps)}
+ {encoderBytes > 0 && <> · {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}>}
+
+ {draft.replay.after_secs > 0 && (
+
+ {t("settings.replay.withAfter", { after: durationLabel(draft.replay.after_secs) })}{" "}
+
+ {t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs + draft.replay.after_secs)) })}
+
+
+ )}
+
+ {t("settings.replay.bufferSize")}{" "}
+ {bytes(estimate.bytes)} {" "}
+ {draft.replay.storage === "ram" ? t("settings.replay.inRam") : t("settings.replay.onDisk")}
+
+ >
+ )}
+ {tooMuchRam && memory && (
+
+
+ {t("settings.replay.ramAlert", {
+ percent: Math.round(ramShare * 100),
+ free: bytes(memory.available_bytes),
+ })}
+
+
replay({ storage: "disk" })}
+ className="pressable h-9 rounded-md border border-line-strong px-3.5 font-medium text-ink hover:bg-surface-hover"
+ >
+ {t("settings.replay.toDisk")}
+
+
+ )}
+
+
+ ({ value: seconds, label: durationLabel(seconds) }))}
+ onChange={(duration_secs) => replay({ duration_secs })}
+ />
+
+
+
+ ({
+ value: seconds,
+ label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds),
+ }))}
+ onChange={(after_secs) => replay({ after_secs })}
+ className="min-w-28"
+ />
+ replay({ after_secs })}
+ />
+
+
+
+
+ label={t("settings.replay.buffer")}
+ value={draft.replay.storage}
+ options={[
+ { value: "ram", label: t("settings.replay.ram") },
+ { value: "disk", label: t("settings.replay.disk") },
+ ]}
+ onChange={(storage) => replay({ storage })}
+ />
+
+
-
-
-
-
- label={t("settings.replay.buffer")}
- value={draft.replay.storage}
- options={[
- { value: "ram", label: t("settings.replay.ram") },
- { value: "disk", label: t("settings.replay.disk") },
- ]}
- onChange={(storage) => replay({ storage })}
- />
-
-
-
+ >
+ )}
-
- {t("settings.replay.recordingPart")}
-
-
- {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"}
- {" "}
- {t("settings.replay.perMinute")}
- {recordEstimate && (
- <>
- {" "}
- ·{" "}
-
- {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })}
- {" "}
- {t("settings.replay.perHour")}
- >
- )}
- {separate && recordEstimate && (
- {technical(recordEstimate, recordFields.fps)}
- )}
-
-
-
- change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } })
- }
- />
-
- {separate && (
-
+ {tab === "recording" && (
+ <>
+
+
+
+ {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"}
+ {" "}
+ {t("settings.replay.perMinute")}
+ {recordEstimate && (
+ <>
+ {" "}
+ ·{" "}
+
+ {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })}
+ {" "}
+ {t("settings.replay.perHour")}
+ >
+ )}
+ {separate && recordEstimate && (
+ {technical(recordEstimate, recordFields.fps)}
+ )}
+
+
+
+
+ change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } })
+ }
+ />
+
+ {separate && (
+
+ )}
+ >
)}
-
-
- {t("settings.replay.commonPart")}
-
-
- label={t("settings.replay.encoder")}
- value={draft.video.encoder}
- options={ENCODER_CHOICES.map((choice) => ({
- value: choice,
- label: t(`settings.replay.encoders.${choice}`),
- disabled: known && choice !== "auto" && !works(choice === "hardware"),
- }))}
- onChange={chooseEncoder}
- />
-
-
- ({
- value: seconds,
- label: t("units.seconds", { value: number(seconds, 1) }),
- }))}
- onChange={(keyframe_interval_secs) => video({ keyframe_interval_secs })}
- />
-
-
-
- label={t("settings.replay.gpuPriority")}
- value={draft.video.gpu_priority}
- options={[
- { value: "game", label: t("settings.replay.gpuPriorities.game") },
- { value: "capture", label: t("settings.replay.gpuPriorities.capture") },
- ]}
- onChange={(gpu_priority) => video({ gpu_priority })}
- />
-
-
+ {tab === "common" && (
+ <>
+
+ {perMinuteLine(t("settings.replay.replayPart"), estimate ? perMinute : null)}
+ {perMinuteLine(t("settings.replay.recordingPart"), recordEstimate ? recordPerMinute : null)}
+ {encoderBytes > 0 && (
+ {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}
+ )}
+
+
+
+ label={t("settings.replay.encoder")}
+ value={draft.video.encoder}
+ options={ENCODER_CHOICES.map((choice) => ({
+ value: choice,
+ label: t(`settings.replay.encoders.${choice}`),
+ disabled: known && choice !== "auto" && !works(choice === "hardware"),
+ }))}
+ onChange={chooseEncoder}
+ />
+
+
+ ({
+ value: seconds,
+ label: t("units.seconds", { value: number(seconds, 1) }),
+ }))}
+ onChange={(keyframe_interval_secs) => video({ keyframe_interval_secs })}
+ />
+
+
+
+ label={t("settings.replay.gpuPriority")}
+ value={draft.video.gpu_priority}
+ options={[
+ { value: "game", label: t("settings.replay.gpuPriorities.game") },
+ { value: "capture", label: t("settings.replay.gpuPriorities.capture") },
+ ]}
+ onChange={(gpu_priority) => video({ gpu_priority })}
+ />
+
+ >
+ )}
+
>
);
diff --git a/app/src/lib/ipc.ts b/app/src/lib/ipc.ts
index f81afa5..dcdac0c 100644
--- a/app/src/lib/ipc.ts
+++ b/app/src/lib/ipc.ts
@@ -162,6 +162,18 @@ export function setFeedback(feedback: Feedback): Promise {
return invoke("set_feedback", { feedback });
}
+export interface PlayerSettings {
+ mute_full_mix: boolean;
+}
+
+export function getPlayer(): Promise {
+ return invoke("get_player");
+}
+
+export function setPlayer(player: PlayerSettings): Promise {
+ return invoke("set_player", { player });
+}
+
export function getState(): Promise {
return invoke("get_state");
}
@@ -299,6 +311,19 @@ export function clipGames(): Promise {
return invoke("clip_games");
}
+export interface ClipMix {
+ tracks: { volume: number; mute: boolean; solo: boolean }[];
+ full_mix_chosen: boolean;
+}
+
+export function clipMix(path: string): Promise {
+ return invoke("clip_mix", { path });
+}
+
+export function setClipMix(path: string, mix: ClipMix): Promise {
+ return invoke("set_clip_mix", { path, mix });
+}
+
export function setClipFavorite(path: string, favorite: boolean): Promise {
return invoke("set_clip_favorite", { path, favorite });
}
@@ -687,11 +712,18 @@ export interface ActiveAlert extends Localized {
export type TrackState = "waiting" | "capturing" | "silent" | "unavailable";
+export interface SourceMode {
+ width: number;
+ height: number;
+ refresh_hz: number | null;
+}
+
export interface CaptureInfo {
source: Localized;
width: number;
height: number;
fps: number;
+ source_mode: SourceMode;
}
export type TrackKind = "mix" | "microphone" | "application" | "game";
@@ -748,8 +780,10 @@ export type EngineEvent =
width: number;
height: number;
fps: number;
+ source_mode: SourceMode;
tracks: TrackDescriptor[];
}
+ | { type: "source_changed"; source_mode: SourceMode }
| { type: "tracks_renamed"; tracks: TrackDescriptor[] }
| { type: "track_changed"; track: number; title: string; state: TrackState; source: string | null }
| { type: "game_named"; exe_name: string; name: string }
@@ -759,6 +793,7 @@ export type EngineEvent =
duration_secs: number;
save_latency_ms: number;
thumbnail: string | null;
+ origin: "replay" | "post_roll" | "recording";
}
| { type: "audio_levels"; levels: number[] }
| { type: "replay_stats"; buffered_secs: number; bytes: number }
diff --git a/app/src/lib/playerAudio.ts b/app/src/lib/playerAudio.ts
index 22197f4..fc1d1b8 100644
--- a/app/src/lib/playerAudio.ts
+++ b/app/src/lib/playerAudio.ts
@@ -19,6 +19,7 @@ export class PlayerAudio {
private context = new AudioContext();
private voices: Voice[] = [];
private timer = 0;
+ private held = false;
constructor(private video: HTMLVideoElement) {
video.addEventListener("play", this.play);
@@ -51,6 +52,15 @@ export class PlayerAudio {
});
}
+ hold() {
+ this.held = true;
+ }
+
+ release() {
+ this.held = false;
+ this.align();
+ }
+
private play = () => {
this.context.resume().catch(() => undefined);
this.align();
@@ -66,6 +76,7 @@ export class PlayerAudio {
};
private align = () => {
+ if (this.held) return;
for (const voice of this.voices) {
voice.element.playbackRate = this.video.playbackRate;
if (Math.abs(voice.element.currentTime - this.video.currentTime) > DRIFT_LIMIT_SECS) {
diff --git a/app/src/lib/playerMix.test.ts b/app/src/lib/playerMix.test.ts
new file mode 100644
index 0000000..8a9ae44
--- /dev/null
+++ b/app/src/lib/playerMix.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "vitest";
+import type { TrackKind } from "./ipc.ts";
+import { FULL_MIX, audibleTracks, changeTrack, heardMix, openingMix } from "./playerMix.ts";
+
+const separate: TrackKind[] = ["mix", "microphone", "game", "application"];
+const mutes = (kinds: TrackKind[], setting = true) => heardMix(kinds, openingMix(kinds.length, null), setting).map((track) => track.mute);
+
+describe("player mix", () => {
+ test("the_full_mix_starts_muted_when_the_clip_has_separate_tracks", () => {
+ expect(mutes(separate)).toEqual([true, false, false, false]);
+ });
+
+ test("a_clip_with_only_the_mix_or_the_mix_and_mic_is_not_muted", () => {
+ expect(mutes(["mix"])).toEqual([false]);
+ expect(mutes(["mix", "microphone"])).toEqual([false, false]);
+ });
+
+ test("the_setting_off_plays_every_track", () => {
+ expect(mutes(separate, false)).toEqual([false, false, false, false]);
+ });
+
+ test("turning_the_full_mix_back_on_sticks_for_the_clip", () => {
+ const opened = openingMix(separate.length, null);
+ const heard = heardMix(separate, opened, true)[0] ?? FULL_MIX;
+ const chosen = changeTrack(separate, opened, true, 0, { ...heard, mute: false });
+ expect(chosen.full_mix_chosen).toBe(true);
+ expect(heardMix(separate, openingMix(separate.length, chosen), true)[0]?.mute).toBe(false);
+ });
+
+ test("other_changes_keep_the_full_mix_muted_by_the_setting_only", () => {
+ const opened = openingMix(separate.length, null);
+ const louder = changeTrack(separate, opened, true, 0, { volume: 0.5, mute: true, solo: false });
+ expect(louder.full_mix_chosen).toBe(false);
+ expect(louder.tracks[0]).toEqual({ volume: 0.5, mute: false, solo: false });
+ const soloed = changeTrack(separate, louder, true, 2, { ...FULL_MIX, solo: true });
+ expect(heardMix(separate, soloed, true).map((track) => track.mute)).toEqual([true, false, false, false]);
+ });
+
+ test("a_clip_reopens_with_its_saved_state_unless_its_tracks_changed", () => {
+ const stored = { tracks: [FULL_MIX, { ...FULL_MIX, volume: 0.3 }, FULL_MIX, FULL_MIX], full_mix_chosen: true };
+ expect(openingMix(separate.length, stored)).toBe(stored);
+ expect(openingMix(separate.length, { ...stored, tracks: stored.tracks.slice(1) }).full_mix_chosen).toBe(false);
+ });
+
+ test("the_export_keeps_track_one_when_only_the_setting_muted_it", () => {
+ const opened = openingMix(separate.length, null);
+ expect(audibleTracks(opened.tracks)).toEqual([true, true, true, true]);
+ const muted = changeTrack(separate, { ...opened, full_mix_chosen: false }, false, 0, { ...FULL_MIX, mute: true });
+ expect(audibleTracks(muted.tracks)).toEqual([false, true, true, true]);
+ });
+});
diff --git a/app/src/lib/playerMix.ts b/app/src/lib/playerMix.ts
new file mode 100644
index 0000000..bf9e9d2
--- /dev/null
+++ b/app/src/lib/playerMix.ts
@@ -0,0 +1,41 @@
+import type { ClipMix, TrackKind } from "./ipc.ts";
+import type { TrackMix } from "./playerAudio.ts";
+
+export const FULL_MIX: TrackMix = { volume: 1, mute: false, solo: false };
+
+export function openingMix(count: number, stored: ClipMix | null): ClipMix {
+ return stored && stored.tracks.length === count
+ ? stored
+ : { tracks: Array.from({ length: count }, () => FULL_MIX), full_mix_chosen: false };
+}
+
+export function mutesFullMix(kinds: TrackKind[], state: ClipMix, setting: boolean): boolean {
+ return (
+ setting &&
+ !state.full_mix_chosen &&
+ kinds.includes("mix") &&
+ kinds.some((kind) => kind === "application" || kind === "game")
+ );
+}
+
+export function heardMix(kinds: TrackKind[], state: ClipMix, setting: boolean): TrackMix[] {
+ const muted = mutesFullMix(kinds, state, setting);
+ return state.tracks.map((track, index) => (muted && kinds[index] === "mix" ? { ...track, mute: true } : track));
+}
+
+export function changeTrack(kinds: TrackKind[], state: ClipMix, setting: boolean, index: number, next: TrackMix): ClipMix {
+ const heard = heardMix(kinds, state, setting)[index];
+ const current = state.tracks[index];
+ if (!heard || !current) return state;
+ const choosesFullMix = kinds[index] === "mix" && next.mute !== heard.mute;
+ const kept = choosesFullMix || kinds[index] !== "mix" ? next : { ...next, mute: current.mute };
+ return {
+ tracks: state.tracks.map((track, place) => (place === index ? kept : track)),
+ full_mix_chosen: state.full_mix_chosen || choosesFullMix,
+ };
+}
+
+export function audibleTracks(mix: TrackMix[]): boolean[] {
+ const solo = mix.some((track) => track.solo);
+ return mix.map((track) => !track.mute && track.volume > 0 && (!solo || track.solo));
+}
diff --git a/app/src/lib/qualityTabs.test.ts b/app/src/lib/qualityTabs.test.ts
new file mode 100644
index 0000000..135140e
--- /dev/null
+++ b/app/src/lib/qualityTabs.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "vitest";
+import type { EngineConfig } from "./ipc.ts";
+import { changedTabs, nextTab, rememberTab, rememberedTab } from "./qualityTabs.ts";
+
+const saved = {
+ replay: { duration_secs: 60, after_secs: 0, storage: "ram", arm_on_startup: true },
+ video: {
+ codec: "h264",
+ quality: "balanced",
+ bitrate_kbps: 40000,
+ fps: 60,
+ resolution: { kind: "native" },
+ encoder: "auto",
+ keyframe_interval_secs: 1,
+ gpu_priority: "game",
+ },
+ record: { video: null },
+} as unknown as EngineConfig;
+
+describe("quality tabs", () => {
+ test("only_the_tab_holding_a_change_is_marked", () => {
+ expect(changedTabs(saved, saved)).toEqual([]);
+ expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, after_secs: 10 } })).toEqual(["replay"]);
+ expect(changedTabs(saved, { ...saved, record: { ...saved.record, video: { fps: 30 } } } as EngineConfig)).toEqual([
+ "recording",
+ ]);
+ expect(changedTabs(saved, { ...saved, video: { ...saved.video, gpu_priority: "capture" } })).toEqual(["common"]);
+ });
+
+ test("settings_outside_the_page_mark_no_tab", () => {
+ expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, arm_on_startup: false } })).toEqual([]);
+ });
+
+ test("arrows_wrap_and_home_end_jump", () => {
+ expect(nextTab("replay", "ArrowRight")).toBe("recording");
+ expect(nextTab("common", "ArrowRight")).toBe("replay");
+ expect(nextTab("replay", "ArrowLeft")).toBe("common");
+ expect(nextTab("recording", "Home")).toBe("replay");
+ expect(nextTab("recording", "End")).toBe("common");
+ expect(nextTab("recording", "Tab")).toBeNull();
+ });
+
+ test("the_chosen_tab_is_kept_for_the_session", () => {
+ expect(rememberedTab()).toBe("replay");
+ rememberTab("recording");
+ expect(rememberedTab()).toBe("recording");
+ });
+});
diff --git a/app/src/lib/qualityTabs.ts b/app/src/lib/qualityTabs.ts
new file mode 100644
index 0000000..b80096b
--- /dev/null
+++ b/app/src/lib/qualityTabs.ts
@@ -0,0 +1,41 @@
+import type { EngineConfig } from "./ipc.ts";
+
+export const QUALITY_TABS = ["replay", "recording", "common"] as const;
+export type QualityTab = (typeof QUALITY_TABS)[number];
+
+const TAB_FIELDS: Record unknown> = {
+ replay: ({ replay, video }) => [
+ replay.duration_secs,
+ replay.after_secs,
+ replay.storage,
+ video.codec,
+ video.quality,
+ video.bitrate_kbps,
+ video.fps,
+ video.resolution,
+ ],
+ recording: ({ record }) => record.video,
+ common: ({ video }) => [video.encoder, video.keyframe_interval_secs, video.gpu_priority],
+};
+
+export function changedTabs(saved: EngineConfig, draft: EngineConfig): QualityTab[] {
+ return QUALITY_TABS.filter((tab) => JSON.stringify(TAB_FIELDS[tab](saved)) !== JSON.stringify(TAB_FIELDS[tab](draft)));
+}
+
+let sessionTab: QualityTab = "replay";
+
+export function rememberedTab(): QualityTab {
+ return sessionTab;
+}
+
+export function rememberTab(tab: QualityTab) {
+ sessionTab = tab;
+}
+
+export function nextTab(current: QualityTab, key: string): QualityTab | null {
+ const index = QUALITY_TABS.indexOf(current);
+ const count = QUALITY_TABS.length;
+ const steps: Record = { ArrowRight: index + 1, ArrowLeft: index - 1 + count, Home: 0, End: count - 1 };
+ const target = steps[key];
+ return target === undefined ? null : (QUALITY_TABS[target % count] ?? null);
+}
diff --git a/app/src/lib/scrub.ts b/app/src/lib/scrub.ts
index 521cc1d..2954163 100644
--- a/app/src/lib/scrub.ts
+++ b/app/src/lib/scrub.ts
@@ -1,4 +1,7 @@
import type { PointerEvent } from "react";
+import type { ScrubSession } from "./scrubSession.ts";
+
+export type Scrubber = Pick;
export const SNAP_PX = 8;
@@ -9,20 +12,22 @@ export function scrubTime(x: number, width: number, span: number, edges: number[
return near ?? (clamped / width) * span;
}
-export function scrubHandlers(span: number, edges: number[], onSeek: (seconds: number) => void) {
- const seekAt = (event: PointerEvent) => {
+export function scrubHandlers(span: number, edges: number[], scrub: Scrubber) {
+ const timeAt = (event: PointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
- onSeek(scrubTime(event.clientX - rect.left, rect.width, span, edges));
+ return scrubTime(event.clientX - rect.left, rect.width, span, edges);
};
return {
onPointerDown: (event: PointerEvent) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
- seekAt(event);
+ scrub.start(timeAt(event));
},
onPointerMove: (event: PointerEvent) => {
- if (event.currentTarget.hasPointerCapture(event.pointerId)) seekAt(event);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) scrub.move(timeAt(event));
},
+ onPointerUp: () => scrub.end(),
+ onPointerCancel: () => scrub.end(),
};
}
diff --git a/app/src/lib/scrubSession.test.ts b/app/src/lib/scrubSession.test.ts
new file mode 100644
index 0000000..625586e
--- /dev/null
+++ b/app/src/lib/scrubSession.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { SETTLE_MS, type ScrubMedia, ScrubSession } from "./scrubSession.ts";
+
+function fakeMedia(playing = false) {
+ const log = { painted: [] as number[], seeks: [] as number[], held: 0, released: [] as boolean[] };
+ const media: ScrubMedia = {
+ playing: () => playing,
+ hold: () => void log.held++,
+ paint: (seconds) => void log.painted.push(seconds),
+ seek: (seconds) => {
+ log.seeks.push(seconds);
+ return true;
+ },
+ release: (resume) => void log.released.push(resume),
+ };
+ return { media, log };
+}
+
+describe("scrub session", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ test("the_cursor_follows_every_move_without_loading_the_video", () => {
+ const { media, log } = fakeMedia();
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ for (const seconds of [2, 3, 4, 5]) {
+ vi.advanceTimersByTime(SETTLE_MS / 4);
+ scrub.move(seconds);
+ }
+ expect(log.painted).toEqual([1, 2, 3, 4, 5]);
+ expect(log.seeks).toEqual([]);
+ });
+
+ test("a_still_pointer_loads_the_frame_after_the_settle_delay", () => {
+ const { media, log } = fakeMedia();
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ scrub.move(7);
+ vi.advanceTimersByTime(SETTLE_MS - 1);
+ expect(log.seeks).toEqual([]);
+ vi.advanceTimersByTime(1);
+ expect(log.seeks).toEqual([7]);
+ });
+
+ test("only_one_load_runs_and_the_latest_position_wins", () => {
+ const { media, log } = fakeMedia();
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ vi.advanceTimersByTime(SETTLE_MS);
+ for (const seconds of [2, 3, 4]) {
+ scrub.move(seconds);
+ vi.advanceTimersByTime(SETTLE_MS);
+ }
+ expect(log.seeks).toEqual([1]);
+ scrub.loaded();
+ expect(log.seeks).toEqual([1, 4]);
+ scrub.loaded();
+ expect(log.seeks).toEqual([1, 4]);
+ });
+
+ test("release_loads_the_exact_position_then_hands_back_the_video", () => {
+ const { media, log } = fakeMedia(true);
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ scrub.move(9.5);
+ scrub.end();
+ expect(log.seeks).toEqual([9.5]);
+ expect(log.released).toEqual([]);
+ expect(scrub.scrubbing).toBe(true);
+ vi.advanceTimersByTime(SETTLE_MS * 2);
+ expect(log.seeks).toEqual([9.5]);
+ scrub.loaded();
+ expect(log.released).toEqual([true]);
+ expect(scrub.scrubbing).toBe(false);
+ expect(log.held).toBe(1);
+ });
+
+ test("release_waits_for_the_load_in_flight_before_the_final_one", () => {
+ const { media, log } = fakeMedia();
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ vi.advanceTimersByTime(SETTLE_MS);
+ scrub.move(6);
+ scrub.end();
+ expect(log.seeks).toEqual([1]);
+ scrub.loaded();
+ expect(log.seeks).toEqual([1, 6]);
+ expect(log.released).toEqual([]);
+ scrub.loaded();
+ expect(log.released).toEqual([false]);
+ });
+
+ test("grabbing_again_before_the_final_load_keeps_one_hold_and_the_first_play_state", () => {
+ const { media, log } = fakeMedia(true);
+ const scrub = new ScrubSession(media);
+ scrub.start(1);
+ scrub.end();
+ scrub.start(3);
+ scrub.loaded();
+ expect(log.released).toEqual([]);
+ scrub.end();
+ scrub.loaded();
+ expect(log.held).toBe(1);
+ expect(log.released).toEqual([true]);
+ });
+
+ test("a_video_that_cannot_seek_yet_is_released_at_once", () => {
+ const { media, log } = fakeMedia();
+ media.seek = (seconds) => {
+ log.seeks.push(seconds);
+ return false;
+ };
+ const scrub = new ScrubSession(media);
+ scrub.start(2);
+ scrub.end();
+ expect(log.released).toEqual([false]);
+ });
+});
diff --git a/app/src/lib/scrubSession.ts b/app/src/lib/scrubSession.ts
new file mode 100644
index 0000000..de15d31
--- /dev/null
+++ b/app/src/lib/scrubSession.ts
@@ -0,0 +1,83 @@
+export const SETTLE_MS = 120;
+
+export interface ScrubMedia {
+ playing(): boolean;
+ hold(): void;
+ paint(seconds: number): void;
+ seek(seconds: number): boolean;
+ release(resume: boolean): void;
+}
+
+export class ScrubSession {
+ private active = false;
+ private resume = false;
+ private loading = false;
+ private next: number | null = null;
+ private releasing = false;
+ private timer: ReturnType | undefined;
+ position = 0;
+
+ constructor(
+ private media: ScrubMedia,
+ private settleMs = SETTLE_MS,
+ ) {}
+
+ get scrubbing() {
+ return this.active || this.releasing;
+ }
+
+ start(seconds: number) {
+ if (!this.scrubbing) {
+ this.resume = this.media.playing();
+ this.media.hold();
+ }
+ this.active = true;
+ this.releasing = false;
+ this.move(seconds);
+ }
+
+ move(seconds: number) {
+ if (!this.active) return;
+ this.position = seconds;
+ this.media.paint(seconds);
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => this.load(seconds), this.settleMs);
+ }
+
+ end() {
+ if (!this.active) return;
+ clearTimeout(this.timer);
+ this.active = false;
+ this.releasing = true;
+ this.load(this.position);
+ if (!this.loading) this.finish();
+ }
+
+ loaded() {
+ this.loading = false;
+ if (this.next !== null) {
+ const seconds = this.next;
+ this.next = null;
+ this.load(seconds);
+ if (this.loading) return;
+ }
+ if (this.releasing) this.finish();
+ }
+
+ dispose() {
+ clearTimeout(this.timer);
+ }
+
+ private load(seconds: number) {
+ if (this.loading) {
+ this.next = seconds;
+ return;
+ }
+ this.loading = this.media.seek(seconds);
+ }
+
+ private finish() {
+ this.releasing = false;
+ this.media.release(this.resume);
+ }
+}
diff --git a/app/src/lib/useEngine.ts b/app/src/lib/useEngine.ts
index a6e3914..c396ec1 100644
--- a/app/src/lib/useEngine.ts
+++ b/app/src/lib/useEngine.ts
@@ -22,7 +22,7 @@ import { useLiveConfig } from "./useConfig.ts";
import { errorText } from "./i18n.ts";
import { clearLevels, pushLevels } from "./levels.ts";
-const RECENT_CLIPS = 5;
+const RECENT_CLIPS = 12;
export type ReplayPhase = "armed" | "idle" | "arming" | "disarming" | "error";
diff --git a/app/src/locales/en.json b/app/src/locales/en.json
index df2c36a..4c2f271 100644
--- a/app/src/locales/en.json
+++ b/app/src/locales/en.json
@@ -221,6 +221,8 @@
"armOnStartup": "Arm the replay on startup",
"sound": "Confirmation sound",
"soundHelp": "Played for every saved clip, even in game.",
+ "muteFullMix": "Mute Full mix during playback",
+ "muteFullMixHelp": "In the player, when the clip has separate tracks. Exports and compression keep track 1.",
"toast": "Confirmation notification",
"toastHelp": "Windows delays it while a game is fullscreen.",
"theme": "Theme",
@@ -349,6 +351,8 @@
"replayPart": "Replay",
"recordingPart": "Recording",
"commonPart": "Shared",
+ "tabs": "Replay and quality parts",
+ "unapplied": "unapplied changes",
"sameAsReplay": "Same as replay",
"forRecording": "{setting} (recording)",
"bufferSize": "Buffer:"
diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json
index bac7412..c549489 100644
--- a/app/src/locales/fr.json
+++ b/app/src/locales/fr.json
@@ -221,6 +221,8 @@
"armOnStartup": "Armer le replay au démarrage",
"sound": "Son de confirmation",
"soundHelp": "Joué à chaque clip sauvegardé, même en jeu.",
+ "muteFullMix": "Couper le Mix complet à la lecture",
+ "muteFullMixHelp": "Dans le lecteur, quand le clip a des pistes séparées. Les exports et la compression gardent la piste 1.",
"toast": "Notification de confirmation",
"toastHelp": "Windows la retient pendant une partie en plein écran.",
"theme": "Thème",
@@ -349,6 +351,8 @@
"replayPart": "Replay",
"recordingPart": "Enregistrement",
"commonPart": "Commun",
+ "tabs": "Parties de Replay et qualité",
+ "unapplied": "changements non appliqués",
"sameAsReplay": "Comme le replay",
"forRecording": "{setting} (enregistrement)",
"bufferSize": "Tampon :"
diff --git a/crates/cli/src/replay.rs b/crates/cli/src/replay.rs
index dccf2d6..de311a8 100644
--- a/crates/cli/src/replay.rs
+++ b/crates/cli/src/replay.rs
@@ -10,7 +10,7 @@ use palaclips_engine::config::{
AppTrack, AudioSettings, RecordSettings, RecordSplit, ReplaySettings, ReplayStorage,
};
use palaclips_engine::engine::{Engine, LevelRate};
-use palaclips_engine::events::{EngineEvent, EngineState};
+use palaclips_engine::events::{EngineEvent, EngineState, SaveOrigin};
use palaclips_engine::paths::private_bytes;
const STATS_EVERY: u32 = 5;
@@ -253,10 +253,14 @@ pub fn run_replay(args: &ReplayArgs) -> Result<()> {
recv(engine.events()) -> event => {
let Ok(event) = event else { break };
match event {
- EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, .. } => {
+ EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, origin, .. } => {
finished += 1;
latencies.push(save_latency_ms);
- chime_on(Sound::Saved);
+ match origin {
+ SaveOrigin::Replay => chime_on(Sound::Saved),
+ SaveOrigin::PostRoll => chime_on(Sound::ClipFinished),
+ SaveOrigin::Recording => {}
+ }
println!(
"[{:>6} s] clip {finished} : {duration_secs:.3} s, prêt en {save_latency_ms} ms ; mémoire privée {:.1} Mo → {path}",
started.elapsed().as_secs(),
@@ -321,8 +325,11 @@ pub fn run_replay(args: &ReplayArgs) -> Result<()> {
println!("nom de jeu : {exe_name} → {name}");
config.storage.game_names.insert(exe_name, name);
}
- EngineEvent::CaptureStarted { source, width, height, fps, tracks } => {
- println!("capture : {source}, {width}x{height} à {fps} i/s, pistes {}", tracks.iter().map(|track| track.title.as_str()).collect::>().join(", "));
+ EngineEvent::CaptureStarted { source, width, height, fps, source_mode, tracks } => {
+ println!("capture : {source} ({}x{} {:?} Hz), {width}x{height} à {fps} i/s, pistes {}", source_mode.width, source_mode.height, source_mode.refresh_hz, tracks.iter().map(|track| track.title.as_str()).collect::>().join(", "));
+ }
+ EngineEvent::SourceChanged { source_mode } => {
+ println!("source : {}x{} {:?} Hz", source_mode.width, source_mode.height, source_mode.refresh_hz);
}
EngineEvent::TracksRenamed { tracks } => {
println!("pistes : {}", tracks.iter().map(|track| track.title.as_str()).collect::>().join(", "));
diff --git a/crates/engine/src/capture/display.rs b/crates/engine/src/capture/display.rs
index 035acc2..fa6f552 100644
--- a/crates/engine/src/capture/display.rs
+++ b/crates/engine/src/capture/display.rs
@@ -8,6 +8,8 @@ use windows::Win32::Devices::Display::{
QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig,
};
use windows::Win32::Foundation::ERROR_SUCCESS;
+use windows::Win32::Graphics::Gdi::{DEVMODEW, ENUM_CURRENT_SETTINGS, EnumDisplaySettingsW};
+use windows::core::HSTRING;
const SCRGB_REFERENCE_NITS: f32 = 80.0;
const ADVANCED_COLOR_ENABLED: u32 = 0b10;
@@ -70,6 +72,24 @@ impl DisplayColor {
}
}
+pub fn refresh_hz(device_name: &str) -> Option {
+ let mut mode = DEVMODEW {
+ dmSize: size_of::() as u16,
+ ..Default::default()
+ };
+ // SAFETY: the device name is a valid wide string and the mode is sized for the call.
+ unsafe {
+ EnumDisplaySettingsW(
+ &HSTRING::from(device_name),
+ ENUM_CURRENT_SETTINGS,
+ &mut mode,
+ )
+ }
+ .as_bool()
+ .then_some(mode.dmDisplayFrequency)
+ .filter(|hz| *hz > 1)
+}
+
pub fn display_color(device_name: &str) -> Option {
let info = display_color_info(device_name)?;
if info.legacy_flags.is_none() && info.info2.is_none() {
diff --git a/crates/engine/src/capture/video.rs b/crates/engine/src/capture/video.rs
index febb33f..fec4286 100644
--- a/crates/engine/src/capture/video.rs
+++ b/crates/engine/src/capture/video.rs
@@ -18,7 +18,7 @@ use windows_capture::settings::{
GraphicsCaptureItemType, MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings,
};
-use super::display::display_color;
+use super::display::{display_color, refresh_hz};
use super::pacer::{CfrClock, bounded_due, emit_due};
use super::rate::{
FreezeWatch, RateWatch, SaturationWatch, composition_rate, exclusive_fullscreen,
@@ -31,7 +31,7 @@ use crate::encode::{
output_size, render_texture,
};
use crate::error::{EngineError, Result};
-use crate::events::{EngineEvent, Localized, Severity};
+use crate::events::{EngineEvent, Localized, Severity, SourceMode};
use crate::perf::CaptureCounters;
const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(3);
@@ -66,6 +66,7 @@ pub struct VideoCapture {
alerts: Receiver,
shared: Arc,
hdr_white: Option,
+ source_screen: Option,
encoder_thread: Option>>,
session: Option>,
}
@@ -83,7 +84,7 @@ impl VideoCapture {
.and_then(display_color)
.filter(|color| color.hdr)
.map(|color| color.sdr_white_scrgb());
- let hdr_watch = display.map(|name| (name, hdr_white.is_some()));
+ let hdr_watch = display.clone().map(|name| (name, hdr_white.is_some()));
let color_format = if hdr_white.is_some() {
ColorFormat::Rgba16F
} else {
@@ -108,6 +109,10 @@ impl VideoCapture {
)
}
};
+ let source_screen = match resolved {
+ ResolvedTarget::Window(_) if alert.is_none() => None,
+ _ => display,
+ };
let first = shared.latest().ok_or_else(|| {
EngineError::Capture("aucune image reçue de Windows Graphics Capture".into())
})?;
@@ -143,6 +148,7 @@ impl VideoCapture {
let shared = shared.clone();
let device = SendCom(device);
let fps = settings.fps;
+ let source_screen = source_screen.clone();
move || {
let hdr = hdr_white
.map(|white| HdrToSdr::new(&device.0, white))
@@ -152,6 +158,7 @@ impl VideoCapture {
packets: packet_tx,
alerts: alert_tx,
hdr_watch,
+ source_screen,
};
run_encoder(shared, device, hdr, encoder, fps, outputs, Role::Primary)
}
@@ -163,6 +170,7 @@ impl VideoCapture {
alerts,
shared,
hdr_white,
+ source_screen,
encoder_thread: Some(encoder_thread),
session: Some(session),
};
@@ -197,6 +205,10 @@ impl VideoCapture {
*self.shared.counters.lock()
}
+ pub fn source_mode(&self) -> Option {
+ source_mode(&self.shared, self.source_screen.as_deref())
+ }
+
pub fn add_output(&self, settings: &VideoSettings, encoder: &str) -> Result {
settings.validate()?;
let latest = self.shared.latest().ok_or_else(|| {
@@ -246,6 +258,7 @@ impl VideoCapture {
packets: packet_tx,
alerts: alert_tx,
hdr_watch: None,
+ source_screen: None,
};
run_encoder(
shared,
@@ -541,6 +554,16 @@ struct EncoderOutputs {
packets: Sender,
alerts: Sender,
hdr_watch: Option<(String, bool)>,
+ source_screen: Option,
+}
+
+fn source_mode(shared: &Shared, screen: Option<&str>) -> Option {
+ let size = shared.latest()?.size;
+ Some(SourceMode {
+ width: size.width,
+ height: size.height,
+ refresh_hz: screen.and_then(refresh_hz),
+ })
}
fn run_encoder(
@@ -558,6 +581,7 @@ fn run_encoder(
packets,
alerts,
hdr_watch,
+ source_screen,
} = outputs;
let device = device.0;
let output = encoder.size();
@@ -604,6 +628,9 @@ fn run_encoder(
let mut next_rate_check = Hns::now() + RATE_CHECK;
let mut frames_at_check = 0;
let mut emitted_at_check = (0, 0);
+ let mut source = primary
+ .then(|| source_mode(&shared, source_screen.as_deref()))
+ .flatten();
while !role.stopped(&shared) {
if shared.closed.load(Ordering::Relaxed) {
encoder.finish(&mut send)?;
@@ -638,6 +665,14 @@ fn run_encoder(
let new_frames = delivered.saturating_sub(frames_at_check);
frames_at_check = delivered;
if primary {
+ if let Some(source_mode) = source_mode(&shared, source_screen.as_deref())
+ && Some(source_mode) != source
+ {
+ source = Some(source_mode);
+ alerts
+ .try_send(EngineEvent::SourceChanged { source_mode })
+ .ok();
+ }
if let Some(alert) =
composition_rate().and_then(|rate| rate_watch.observe(now, rate, new_frames))
{
diff --git a/crates/engine/src/chime.rs b/crates/engine/src/chime.rs
index 378dec3..fc0e638 100644
--- a/crates/engine/src/chime.rs
+++ b/crates/engine/src/chime.rs
@@ -9,6 +9,7 @@ pub enum Sound {
Saved,
RecordingStarted,
RecordingStopped,
+ ClipFinished,
}
impl Sound {
@@ -17,6 +18,11 @@ impl Sound {
Self::Saved => &[(880.0, 0.0, 0.11), (1318.5, 0.075, 0.2)],
Self::RecordingStarted => &[(523.25, 0.0, 0.1), (783.99, 0.075, 0.2)],
Self::RecordingStopped => &[(783.99, 0.0, 0.1), (523.25, 0.075, 0.2)],
+ Self::ClipFinished => &[
+ (880.0, 0.0, 0.1),
+ (1318.5, 0.075, 0.1),
+ (1760.0, 0.15, 0.22),
+ ],
}
}
@@ -25,6 +31,7 @@ impl Sound {
Self::Saved => 0,
Self::RecordingStarted => 1,
Self::RecordingStopped => 2,
+ Self::ClipFinished => 3,
}
}
}
@@ -80,7 +87,7 @@ pub fn play(sound: Sound) {
use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_MEMORY, SND_NODEFAULT};
use windows::core::PCWSTR;
- static SOUNDS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
+ static SOUNDS: [OnceLock>; 4] = [const { OnceLock::new() }; 4];
let chime = SOUNDS[sound.index()].get_or_init(|| wav(&sound_samples(sound.notes())));
// SAFETY: SND_MEMORY reads the WAV from `chime`, a static buffer that outlives the
// asynchronous playback.
@@ -100,13 +107,39 @@ pub fn play(sound: Sound) {
mod tests {
use super::*;
+ const ALL: [Sound; 4] = [
+ Sound::Saved,
+ Sound::RecordingStarted,
+ Sound::RecordingStopped,
+ Sound::ClipFinished,
+ ];
+
+ #[test]
+ fn all_sounds_differ() {
+ let samples: Vec<_> = ALL.map(|sound| sound_samples(sound.notes())).into();
+ for (index, sound) in samples.iter().enumerate() {
+ for other in &samples[index + 1..] {
+ assert_ne!(sound, other);
+ }
+ }
+ let indices: std::collections::HashSet<_> = ALL.map(Sound::index).into();
+ assert_eq!(indices.len(), ALL.len());
+ }
+
#[test]
- fn recording_sounds_differ_from_the_save_chime() {
- let saved = sound_samples(Sound::Saved.notes());
- let started = sound_samples(Sound::RecordingStarted.notes());
- let stopped = sound_samples(Sound::RecordingStopped.notes());
- assert_ne!(saved, started);
- assert_ne!(started, stopped);
+ fn the_finished_clip_sound_keeps_the_timbre_and_volume_of_the_chime() {
+ let peak = |sound: Sound| {
+ sound_samples(sound.notes())
+ .iter()
+ .map(|sample| sample.unsigned_abs())
+ .max()
+ .unwrap_or(0)
+ };
+ let saved = f32::from(peak(Sound::Saved));
+ let finished = f32::from(peak(Sound::ClipFinished));
+ assert!((finished / saved - 1.0).abs() < 0.15, "{saved} {finished}");
+ let seconds = sound_samples(Sound::ClipFinished.notes()).len() as f32 / SAMPLE_RATE as f32;
+ assert!(seconds < 0.4, "{seconds}");
}
#[test]
diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs
index 064d43c..2e2913f 100644
--- a/crates/engine/src/engine.rs
+++ b/crates/engine/src/engine.rs
@@ -25,7 +25,8 @@ use crate::encode::{
};
use crate::error::{EngineError, Result};
use crate::events::{
- CapturePerformance, EngineEvent, EngineState, Localized, Severity, TrackDescriptor, TrackKind,
+ CapturePerformance, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode,
+ TrackDescriptor, TrackKind,
};
use crate::library::ensure_free_space;
use crate::mux::{Mp4Writer, MuxStream};
@@ -1067,6 +1068,11 @@ impl Pipeline {
width: video.size.width,
height: video.size.height,
fps: video.fps,
+ source_mode: capture.source_mode().unwrap_or(SourceMode {
+ width: video.size.width,
+ height: video.size.height,
+ refresh_hz: None,
+ }),
tracks: track_descriptors(config),
})
.ok();
@@ -1822,6 +1828,7 @@ impl SaveJob {
duration_secs: self.duration.as_secs_f64(),
save_latency_ms: (latency.0 / 10_000) as u64,
thumbnail,
+ origin: SaveOrigin::Replay,
})
}
}
diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs
index e763c3e..cbd40b4 100644
--- a/crates/engine/src/events.rs
+++ b/crates/engine/src/events.rs
@@ -87,6 +87,21 @@ pub enum TrackState {
Unavailable,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SaveOrigin {
+ Replay,
+ PostRoll,
+ Recording,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+pub struct SourceMode {
+ pub width: u32,
+ pub height: u32,
+ pub refresh_hz: Option,
+}
+
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngineEvent {
@@ -98,8 +113,12 @@ pub enum EngineEvent {
width: u32,
height: u32,
fps: u32,
+ source_mode: SourceMode,
tracks: Vec,
},
+ SourceChanged {
+ source_mode: SourceMode,
+ },
TracksRenamed {
tracks: Vec,
},
@@ -118,6 +137,7 @@ pub enum EngineEvent {
duration_secs: f64,
save_latency_ms: u64,
thumbnail: Option,
+ origin: SaveOrigin,
},
AudioLevels {
levels: Vec,
diff --git a/crates/engine/src/library/index.rs b/crates/engine/src/library/index.rs
index b04ddb7..4ac8d37 100644
--- a/crates/engine/src/library/index.rs
+++ b/crates/engine/src/library/index.rs
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS clips (
fps REAL NOT NULL DEFAULT -1,
tracks TEXT NOT NULL,
thumbnail TEXT,
- favorite INTEGER NOT NULL DEFAULT 0
+ favorite INTEGER NOT NULL DEFAULT 0,
+ mix TEXT
);
CREATE INDEX IF NOT EXISTS clips_by_date ON clips(modified_ms);
";
@@ -193,13 +194,17 @@ impl Library {
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(db_error)?;
conn.execute_batch(SCHEMA).map_err(db_error)?;
- let has_fps = conn
- .prepare("SELECT 1 FROM pragma_table_info('clips') WHERE name = 'fps'")
- .and_then(|mut statement| statement.exists([]))
- .map_err(db_error)?;
- if !has_fps {
- conn.execute_batch("ALTER TABLE clips ADD COLUMN fps REAL NOT NULL DEFAULT -1")
+ for (column, definition) in [("fps", "REAL NOT NULL DEFAULT -1"), ("mix", "TEXT")] {
+ let exists = conn
+ .prepare("SELECT 1 FROM pragma_table_info('clips') WHERE name = ?1")
+ .and_then(|mut statement| statement.exists([column]))
+ .map_err(db_error)?;
+ if !exists {
+ conn.execute_batch(&format!(
+ "ALTER TABLE clips ADD COLUMN {column} {definition}"
+ ))
.map_err(db_error)?;
+ }
}
Ok(Self {
conn,
@@ -471,6 +476,29 @@ impl Library {
Ok(())
}
+ pub fn set_mix(&self, path: &Path, mix: &str) -> Result<()> {
+ self.conn
+ .execute(
+ "UPDATE clips SET mix = ?2 WHERE path = ?1",
+ params![key(path), mix],
+ )
+ .map_err(db_error)?;
+ Ok(())
+ }
+
+ pub fn mix(&self, path: &Path) -> Result> {
+ Ok(self
+ .conn
+ .query_row(
+ "SELECT mix FROM clips WHERE path = ?1",
+ params![key(path)],
+ |row| row.get(0),
+ )
+ .optional()
+ .map_err(db_error)?
+ .flatten())
+ }
+
pub fn rename(&self, path: &Path, name: &str) -> Result {
let name = name.trim();
check_file_name(name)?;
@@ -775,6 +803,42 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
+ #[test]
+ fn a_clip_keeps_its_player_mix_through_a_rename_and_a_rescan() {
+ let dir = scratch("mix");
+ let clips = dir.join("clips");
+ let original = clips.join("Fortnite").join("Fortnite_a_replay.mp4");
+ write_clip(&original, 1, 1);
+ let mut library = library(&dir);
+ library.sync().expect("sync");
+ assert_eq!(library.mix(&original).expect("mix"), None);
+ library.set_mix(&original, "[1]").expect("set mix");
+ let renamed = library.rename(&original, "Top 1").expect("rename");
+ library.sync().expect("sync");
+ assert_eq!(library.mix(&renamed).expect("mix").as_deref(), Some("[1]"));
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn a_library_from_before_the_player_mix_gains_the_column() {
+ let dir = scratch("old-schema");
+ std::fs::create_dir_all(&dir).expect("dir");
+ let db = dir.join("library.sqlite");
+ Connection::open(&db)
+ .and_then(|conn| {
+ conn.execute_batch(
+ "CREATE TABLE clips (path TEXT PRIMARY KEY, name TEXT NOT NULL, game TEXT NOT NULL,
+ kind TEXT NOT NULL, modified_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL,
+ duration_secs REAL NOT NULL, tracks TEXT NOT NULL, thumbnail TEXT,
+ favorite INTEGER NOT NULL DEFAULT 0);",
+ )
+ })
+ .expect("old schema");
+ let library = Library::open(&db, dir.join("clips")).expect("open");
+ assert_eq!(library.mix(Path::new("absent.mp4")).expect("mix"), None);
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
#[test]
fn a_renamed_clip_keeps_its_favorite_and_refuses_taken_or_invalid_names() {
let dir = scratch("rename");
diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs
index 889b395..a459190 100644
--- a/crates/engine/src/record.rs
+++ b/crates/engine/src/record.rs
@@ -14,7 +14,7 @@ use crate::clock::{HNS_PER_SECOND, Hns};
use crate::config::RecordSplit;
use crate::encode::{route_ffmpeg_logs, write_thumbnail};
use crate::error::{EngineError, Result};
-use crate::events::{EngineEvent, Localized, Severity};
+use crate::events::{EngineEvent, Localized, SaveOrigin, Severity};
use crate::library::{STOP_FREE_BYTES, WARN_FREE_BYTES, disk_space};
use crate::mux::{Mp4Writer, MuxStream};
use crate::paths;
@@ -347,7 +347,12 @@ impl Segment {
self.writer.write(stream, packet)
}
- fn finish(self, layout: &RecordLayout, journal: &Journal) -> Result {
+ fn finish(
+ self,
+ layout: &RecordLayout,
+ journal: &Journal,
+ kind: RecordKind,
+ ) -> Result {
self.writer.finish()?;
journal.remove(&self.path)?;
let duration = self.newest_video + layout.frame - self.start;
@@ -371,6 +376,10 @@ impl Segment {
duration_secs: duration.as_secs_f64(),
save_latency_ms: 0,
thumbnail,
+ origin: match kind {
+ RecordKind::Recording => SaveOrigin::Recording,
+ RecordKind::Replay => SaveOrigin::PostRoll,
+ },
})
}
}
@@ -457,7 +466,7 @@ impl Writer {
fn close_previous(&mut self) -> Result<()> {
self.splitter.close_previous();
if let Some(previous) = self.previous.take() {
- let event = previous.finish(&self.layout, &self.journal)?;
+ let event = previous.finish(&self.layout, &self.journal, self.target.kind)?;
self.events.try_send(event).ok();
}
Ok(())
@@ -465,7 +474,9 @@ impl Writer {
fn finish(mut self) -> Result<()> {
let previous = self.close_previous();
- let event = self.current.finish(&self.layout, &self.journal)?;
+ let event = self
+ .current
+ .finish(&self.layout, &self.journal, self.target.kind)?;
self.events.try_send(event).ok();
previous
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 199725f..5e1e700 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -307,6 +307,7 @@ d'une erreur système.
| `set_config { patch }` → `EngineConfig` | 5 ✅ (patch : ADR-039) | `patch` = seulement les champs changés, fusionné dans la config en cours (objet sans `kind` : champ par champ ; objet à `kind` et liste : remplacés entiers) ; si la vidéo ou la capture change : limite du codec puis essai réel d'ouverture de l'encodeur à la taille de sortie (refus = `output_too_large` / `output_unsupported` avec codec et taille, rien n'est appliqué) ; valider, enregistrer `settings.json` (écriture atomique), appliquer au moteur (redémarre le replay si la capture ou les sources changent) ; refusé si le fichier vient d'une version plus récente |
| `get_autostart` / `set_autostart { enabled }` | 5 ✅ | démarrage avec Windows (APP-04, `tauri-plugin-autostart` 2.7, clé `Run` de l'utilisateur) ; lancé avec `--autostart` : zone de notification seulement, aucune fenêtre ni WebView2, puis « armer au démarrage » comme d'habitude |
| `get_feedback` / `set_feedback { feedback }` | 5 ✅ | confirmation de sauvegarde : `{ sound, toast }` (RPL-06), enregistrée dans `settings.json` |
+| `get_player` / `set_player { player }` | v1.4 | lecteur : `{ mute_full_mix }` (vrai par défaut), enregistré dans `settings.json` |
| `reset_settings` | 5 ✅ | tous les réglages par défaut (APP-07) : moteur, confirmation, raccourcis, thème, démarrage avec Windows désactivé |
| `get_theme` / `set_theme { theme }` | 5 ✅ | `system` / `dark` / `light` ; le front pose `data-theme` sur `` (système suivi par `matchMedia`) |
| `get_language` / `set_language { language }` | 5 ✅ | APP-08, ADR-028 : `system` / `fr` / `en` ; `get_language` renvoie `{ setting, resolved }` (`system` = langue d'affichage de Windows, français si elle est française, anglais sinon), `set_language` renvoie la langue retenue et retraduit le menu de la zone de notification |
@@ -340,6 +341,7 @@ d'une erreur système.
| `estimate_replay { config }` | 5 ✅ | estimation du tampon pour une config non appliquée (RPL-03) : taille de la source, taille de sortie (jamais plus haute que la source), débit vidéo, nombre de pistes, octets (fenêtre du tampon + 7 %), `file_bytes_per_minute` (taille d'un fichier par minute, RPL-11 : débit vidéo + 192 kb/s par piste + 1 % de conteneur). Réglages alerte au-delà de 40 % de la RAM disponible et propose le tampon sur disque |
| `list_clips { query }` | 6 ✅ | clips de la bibliothèque (index SQLite) : filtres `game`, `kind` (`replay` / `recording`, d'après le nom), `favorites`, `since_ms`, `search` (nom ou jeu), tri `newest` / `oldest` / `largest` / `longest` / `name`, `limit` ; seuls les clips du dossier courant (racine + un niveau). Chaque clip : `path`, `name`, `game`, `kind`, `modified_ms`, `size_bytes`, `duration_secs`, `fps` (cadence vidéo moyenne, pas d'image du lecteur), `tracks` (titres), `thumbnail`, `favorite` |
| `clip_games` | 6 ✅ | jeux (sous-dossiers) présents dans la bibliothèque |
+| `clip_mix { path }` / `set_clip_mix { path, mix }` | v1.4 | GAL-04 : état du lecteur par clip, `{ tracks: [{ volume, mute, solo }], full_mix_chosen }` (colonne `mix` de la bibliothèque, suit le renommage ; volume borné à 0–1) ; `null` = jamais réglé. `full_mix_chosen` = la piste 1 a été coupée ou réactivée à la main, sinon le réglage `mute_full_mix` s'applique par-dessus |
| `set_clip_favorite { path, favorite }` / `rename_clip { path, name }` / `trash_clip { path }` / `reveal_clip { path }` | 6 ✅ | GAL-05 : favori ; renommer le fichier (mêmes règles de nom que les dossiers de jeux, miniature renommée, favori gardé) ; Corbeille (`SHFileOperationW` + `FOF_ALLOWUNDO`) ; Explorateur sur le fichier. Un chemin absent de la bibliothèque est refusé (`clip_unknown`) |
| `quota_preview { quotaGb }` | 6 ✅ | STO-04 : taille totale et clips qui seraient supprimés (plus anciens, jamais un favori ni un enregistrement en cours) |
| `prepare_player { path }` | 6 ✅ | ADR-008 : pistes audio du clip copiées (sans réencodage) en `.m4a`, une par piste, en une seule lecture du fichier ; cache `%LOCALAPPDATA%\com.palawi.palaclips\cache\tracks\` limité à 2 Go (les moins récemment utilisées sont supprimées, les `.part` nettoyés au démarrage) ; renvoie `[{ title, path }]` |
@@ -355,11 +357,12 @@ d'une erreur système.
Émis tels quels sur l'événement Tauri `engine-event` par le fil `palaclips-events`, qui tient
aussi l'`AppStatus` renvoyé par `get_state`.
-`state_changed`, `capture_started { source, width, height, fps, tracks: [{ title, kind }] }` (à l'armement ; `source` = `Localized` : `source_primary_screen`, `source_screen { name }`, `source_window { exe }`, `source_window_fallback { exe }` ; `kind` = `mix`, `microphone`, `application` ou `game`, car les pistes sont renommables),
+`state_changed`, `capture_started { source, width, height, fps, source_mode, tracks: [{ title, kind }] }` (à l'armement ; `width`/`height`/`fps` = sortie encodée, `source_mode { width, height, refresh_hz }` = taille réelle de l'écran ou de la fenêtre capturée et fréquence de l'écran, `null` pour une fenêtre ; `source` = `Localized` : `source_primary_screen`, `source_screen { name }`, `source_window { exe }`, `source_window_fallback { exe }` ; `kind` = `mix`, `microphone`, `application` ou `game`, car les pistes sont renommables),
+`source_changed { source_mode }` (la source change de taille ou l'écran de fréquence pendant la capture, relevé chaque seconde),
`tracks_renamed { tracks: [{ title, kind }] }` (nom de piste ou langue changés pendant le
replay : titres mis à jour sans redémarrage, appliqués au prochain clip), `game_named { exe_name, name }` (nom de jeu choisi et figé, ADR-024 ; l'app l'ajoute à
`settings.json`), `track_changed { track, title, state: waiting | capturing | silent | unavailable, source }` (appli ou
-périphérique rattaché ; émis à chaque changement), `clip_saved`, `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis
+périphérique rattaché ; émis à chaque changement), `clip_saved { path, duration_secs, save_latency_ms, thumbnail, origin }` (`origin` = `replay`, `post_roll` ou `recording` : le shell joue le carillon pour un replay, le son « clip terminé » à la fin d'un « après », rien pour un enregistrement, qui a déjà son son de fin), `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis
le relevé précédent, un nombre par piste dans l'ordre des pistes : le front indexe par numéro
de piste, jamais par titre ; 20 Hz fenêtre focalisée, 10 Hz sans focus, coupé
fenêtre réduite ou fermée : `EngineHandle::set_level_rate`, piloté par les événements de la
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 490d77d..d3e0d3d 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -54,9 +54,20 @@
captées et 61 ms d'avance sur le loopback Voicemeeter ; choix de Palawi : source au choix,
« Tous les sons sauf PalaClips » par défaut, #118, ADR-041) ; listes de clips à jour partout
(#119) ; déplacement dans le lecteur depuis les pistes et calage (#120) ; session de Palawi
- validée ; préparation (#121). **Release : brouillon, publication en attente de l'accord de
- Palawi.** Branche : `dev`.
-- **Dernière mise à jour :** 2026-10-26
+ validée ; préparation (#121), `dev` → `main` (#122, accord de Palawi), tag `v1.3.1`,
+ **publiée le 2026-10-26** (https://github.com/Palawizard/PalaClips/releases/tag/v1.3.1) avec
+ l'accord de Palawi. Brouillon relu connecté (version, signature = `.sig`, liens publics) ;
+ vérifié sans connexion après publication : `latest.json` 1.3.1, installeur, `.sig`, page et
+ CHANGELOG en 200. Branche : `dev`.
+- **v1.4.0 en préparation (2026-10-27/28)** : six retours de Palawi, une PR chacun : défilement
+ d'État et 12 clips (#125), cellule Source = vraie source (#126, `SourceMode`, `source_changed`),
+ glisser fluide dans le lecteur (#124, `ScrubSession`), son « clip terminé » (#127,
+ `clip_saved.origin`), Replay & qualité en onglets (#128), Mix complet coupé à la lecture et
+ état par clip (#129, `player.mute_full_mix`, colonne `mix`). Décisions et mesures :
+ `docs/reports/v1.4.md`. Session manuelle de Palawi validée le 2026-10-28. Préparation de la
+ version : branche `chore/release-v1.4.0`. Reste : PR `dev` → `main`, tag et brouillon **avec
+ l'accord de Palawi**, puis publication avec son accord.
+- **Dernière mise à jour :** 2026-10-28
## Ce qui existe
@@ -296,12 +307,25 @@
`size-estimate.ps1`, `many-tracks.ps1 [-Keep]`, `app-isolated.ps1 -RightScreen`. Preuves :
`docs/reports/v1.3/`.
+- **v1.4 (2026-10-27/28)** :
+ - Moteur : `events::SourceMode` (`capture_started.source_mode`, `source_changed` relevé chaque
+ seconde dans le fil d'encodage, `capture::display::refresh_hz`) ; `events::SaveOrigin`
+ (`clip_saved.origin` : `replay`, `post_roll`, `recording`) ; `chime::Sound::ClipFinished` ;
+ `Library::mix` / `set_mix` (colonne `mix`, ajoutée aux bases existantes).
+ - Shell : `player.mute_full_mix` dans `settings.json` (`get_player` / `set_player`),
+ `clip_mix` / `set_clip_mix` (`ClipMix { tracks, full_mix_chosen }`, volume borné) ; son joué
+ selon `origin` (rien pour un enregistrement, qui a son propre son de fin).
+ - Front : `lib/scrubSession.ts` (glisser : dessin immédiat, chargement après 120 ms ou au
+ relâchement, dernière position gagnante, audio tenu par `PlayerAudio.hold/release`) ;
+ `lib/qualityTabs.ts` (onglets, onglet retenu, `Destination.tab`) ; `lib/playerMix.ts`
+ (coupure du Mix complet seulement avec une piste Jeu ou appli, export sur l'état choisi).
+ - Scripts : `cdp-run.ps1` (app isolée + script CDP, sortie dans `target/journals/