From 15d57f95b19ebde4acbd5d6ac8ca00b68614f15f Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 1/4] feat(library): keep the player mix of each clip --- crates/engine/src/library/index.rs | 78 +++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) 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"); From 0e177ab7a2e487a1c753ebece54f432421f1e3ba Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 2/4] feat(ui): mute the full mix during playback and remember the mix per clip --- app/src-tauri/src/clips.rs | 57 ++++++++++++++++- app/src-tauri/src/commands.rs | 30 ++++++++- app/src-tauri/src/lib.rs | 11 ++++ app/src-tauri/src/settings.rs | 17 +++++ app/src/components/gallery/Player.tsx | 62 ++++++++++++++----- .../components/settings/GeneralSection.tsx | 14 +++++ app/src/lib/ipc.ts | 25 ++++++++ app/src/lib/playerMix.test.ts | 51 +++++++++++++++ app/src/lib/playerMix.ts | 41 ++++++++++++ app/src/locales/en.json | 2 + app/src/locales/fr.json | 2 + 11 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 app/src/lib/playerMix.test.ts create mode 100644 app/src/lib/playerMix.ts 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 1e7835b..067f58a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -297,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() @@ -743,6 +750,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, @@ -763,6 +772,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, 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/components/gallery/Player.tsx b/app/src/components/gallery/Player.tsx index 9b52ac6..fd13356 100644 --- a/app/src/components/gallery/Player.tsx +++ b/app/src/components/gallery/Player.tsx @@ -1,6 +1,6 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import { ArrowLeft, Copy, FolderOpen, Pause, Play, Star, Trash2 } from "lucide-react"; -import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { bytes, clock } from "../../lib/format.ts"; import { autoNamed, clipTitle, dayLabel, preciseTime, timeOfDay } from "../../lib/gallery.ts"; import { errorText, t as translate, useT } from "../../lib/i18n.ts"; @@ -10,11 +10,16 @@ import { type TrackFile, type TrackKind, copyClip, + type ClipMix, + clipMix, + getPlayer, preparePlayer, + setClipMix, revealClip, setClipFavorite, } from "../../lib/ipc.ts"; import { PlayerAudio, type TrackMix } from "../../lib/playerAudio.ts"; +import { FULL_MIX, audibleTracks, changeTrack, heardMix, openingMix } from "../../lib/playerMix.ts"; import { ScrubSession } from "../../lib/scrubSession.ts"; import { type TrackLabels, kindOfTitle, trackColor } from "../../lib/tracks.ts"; import type { DraftRequest } from "../../lib/compressDrafts.ts"; @@ -24,7 +29,7 @@ import { MIN_RANGE_SECS, type Range, Timeline } from "./Timeline.tsx"; import { TrackLane } from "./TrackLane.tsx"; const STEP_SECS = 1; -const FULL_MIX: TrackMix = { volume: 1, mute: false, solo: false }; +const MIX_SAVE_MS = 400; const BUTTON = "pressable inline-flex h-9 items-center gap-2 rounded-md border border-line-strong px-3 font-medium text-ink hover:bg-surface-hover"; const HEADER_LABEL = "max-[1100px]:sr-only"; @@ -40,11 +45,6 @@ function playerShortcuts(): Shortcut[] { ]; } -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)); -} - export function Player({ clip, labels, @@ -97,7 +97,9 @@ export function Player({ 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; @@ -106,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)), @@ -131,8 +138,15 @@ export function Player({ }, [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; @@ -246,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 (
@@ -365,7 +395,7 @@ 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)))} + onMix={(next) => changeMix(index, next)} scrub={scrub} /> ))} @@ -378,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/settings/GeneralSection.tsx b/app/src/components/settings/GeneralSection.tsx index 56cc01e..f6fd933 100644 --- a/app/src/components/settings/GeneralSection.tsx +++ b/app/src/components/settings/GeneralSection.tsx @@ -11,11 +11,13 @@ import { openDiagnostic, getAutostart, getFeedback, + getPlayer, getLanguage, getTheme, resetSettings, setAutostart, setFeedback, + setPlayer, setLanguageSetting, setTheme, } from "../../lib/ipc.ts"; @@ -155,6 +157,7 @@ export function GeneralSection() { const [autostart, setAutostartState] = useState(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 && ( + + )} + { 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 }); } 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/locales/en.json b/app/src/locales/en.json index df2c36a..d0c5541 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", diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json index bac7412..a0d8f45 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", From 8db05b21994e0ff74b7bfc3a2df02707eb4bcc2a Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 3/4] docs(docs): document the player mix setting and per clip state --- docs/ARCHITECTURE.md | 2 ++ docs/SPEC.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03de870..9e58df0 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 }]` | diff --git a/docs/SPEC.md b/docs/SPEC.md index 9b2b260..71be51e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -108,7 +108,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1). | GAL-01 | Grille de clips avec miniature, durée, jeu, date, taille, type (replay / enregistrement). | | GAL-02 | Filtres (jeu, type, favoris, période), tri, recherche par nom ; fluide avec 5 000 clips. | | GAL-03 | Lecteur intégré avec déplacement fluide, y compris sur un fichier de 4 Go ; clic ou glisser sur la barre de temps **ou sur n'importe quelle piste audio** (v1.3.1), la tête de lecture se cale sur l'entrée et la sortie à 8 px près et suit l'entrée quand on la déplace. Pendant le glisser (v1.4), 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 seule demande à la fois (la dernière position gagne) ; l'audio se recale à la fin et la lecture reprend si elle tournait. | -| GAL-04 | Dans le lecteur : **chaque piste audio** peut être coupée, isolée (solo) ou réglée en volume. | +| GAL-04 | Dans le lecteur : **chaque piste audio** peut être coupée, isolée (solo) ou réglée en volume. v1.4 : 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 (jamais si le clip n'a que le mix, ou le mix et le micro) ; elle se réactive dans le lecteur. L'état muet/solo/volume est retenu par clip. L'export et la compression ne suivent pas cette coupure automatique : la piste 1 reste dans les fichiers sauf si on l'a coupée soi-même. | | GAL-05 | Renommer, favori, supprimer (vers la Corbeille), ouvrir l'emplacement, **copier le fichier** dans le presse-papiers (glisser dans Discord). | | GAL-06 | Découpe : export **rapide** sans réencodage (sur images clés) ou **précis** (réencodage matériel). | | GAL-07 | Export : choix des pistes à garder, ou **mixage en une seule piste** (partage Discord/réseaux). | From 9e17b2d8a8183b2f25a05ccda0058926d40dbf06 Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:58:32 +0100 Subject: [PATCH 4/4] test(scripts): check the player mix in the isolated app --- scripts/player-mix-check.mjs | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 scripts/player-mix-check.mjs diff --git a/scripts/player-mix-check.mjs b/scripts/player-mix-check.mjs new file mode 100644 index 0000000..4e2640d --- /dev/null +++ b/scripts/player-mix-check.mjs @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { connect } from "./cdp.mjs"; + +const [port, outDir] = process.argv.slice(2); +mkdirSync(outDir, { recursive: true }); +const { send, page, evaluate, close } = await connect(port); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const results = []; +async function until(what, expression, timeout = 20_000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await evaluate(expression)) return; + await sleep(150); + } + throw new Error(`${what} : rien après ${timeout} ms`); +} +async function check(label, run) { + try { + const detail = await run(); + results.push(true); + console.log(`OK ${label}${detail ? ` : ${detail}` : ""}`); + } catch (error) { + results.push(false); + console.log(`ÉCHEC ${label} : ${error.message || error.stack || error}`); + } +} +const expect = (condition, message) => { + if (!condition) throw new Error(message); +}; +const nav = (name) => + page((text) => [...document.querySelectorAll("nav button")].find((item) => item.textContent.trim().startsWith(text))?.click(), name); +const clips = () => page(() => [...document.querySelectorAll("[data-clip]")].map((card) => card.dataset.clip)); +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +async function open(path) { + await page(() => document.querySelector('button[aria-label="Retour à la galerie"]')?.click()); + await sleep(300); + await nav("Galerie"); + await until("galerie", "document.querySelectorAll('[data-clip]').length >= 3"); + await page((target) => [...document.querySelectorAll("[data-clip]")].find((card) => card.dataset.clip === target).click(), path); + await until("pistes", "document.querySelectorAll('.track-lane').length === 3"); + await sleep(600); +} +const lanes = () => + page(() => + [...document.querySelectorAll(".track-lane")].map((lane) => ({ + muted: lane.querySelector("button[aria-pressed]").getAttribute("aria-pressed") === "true", + audible: lane.dataset.audible === "true", + volume: Number(lane.querySelector("input[type=range]").value), + })), + ); +const exported = () => page(() => [...document.querySelectorAll("button[role=checkbox]")].map((box) => box.getAttribute("aria-checked") === "true")); +async function shoot(name) { + const { result } = await send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(outDir, `${name}.png`), Buffer.from(result.data, "base64")); +} + +await until("app", "document.querySelectorAll('nav button').length > 0"); +await nav("Galerie"); +await until("galerie", "document.querySelectorAll('[data-clip]').length >= 3"); +const [first, second, third] = await clips(); + +await check("ouverture : Mix complet coupé, Micro et Discord joués, export avec les 3 pistes", async () => { + await open(first); + const state = await lanes(); + expect(state.map((lane) => lane.muted).join() === "true,false,false", JSON.stringify(state)); + await page(() => [...document.querySelectorAll("button[aria-expanded]")].find((button) => button.textContent.includes("Pistes"))?.click()); + await sleep(300); + const boxes = await exported(); + expect(boxes.join() === "true,true,true", `export : ${boxes.join()}`); + await shoot("player-opened"); + return "piste 1 coupée, export 3/3"; +}); + +await check("réactiver la piste 1 et baisser le Micro sont retenus pour ce clip", async () => { + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await page(() => { + const slider = document.querySelectorAll(".track-lane input[type=range]")[1]; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(slider, "30"); + slider.dispatchEvent(new Event("input", { bubbles: true })); + }); + await sleep(900); + await open(second); + await open(first); + const state = await lanes(); + expect(state[0].muted === false && state[1].volume === 30, JSON.stringify(state)); + return "piste 1 active, Micro à 30 %"; +}); + +await check("un autre clip repart du réglage", async () => { + await open(second); + const state = await lanes(); + expect(state[0].muted && !state[1].muted, JSON.stringify(state)); + return "piste 1 coupée"; +}); + +await check("couper la piste 1 soi-même la retire de l'export", async () => { + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await sleep(200); + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await sleep(300); + await page(() => [...document.querySelectorAll("button[aria-expanded]")].find((button) => button.textContent.includes("Pistes"))?.click()); + await sleep(300); + const boxes = await exported(); + expect(boxes.join() === "false,true,true", `export : ${boxes.join()}`); + return "export 2/3"; +}); + +await check("réglage désactivé : rien n'est coupé", async () => { + await nav("Réglages"); + await sleep(300); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Général")?.click()); + await until("réglage", "[...document.querySelectorAll('[role=switch]')].some((item) => item.getAttribute('aria-label') === 'Couper le Mix complet à la lecture')"); + const before = await page(() => { + const toggle = [...document.querySelectorAll("[role=switch]")].find((item) => item.getAttribute("aria-label") === "Couper le Mix complet à la lecture"); + const checked = toggle.getAttribute("aria-checked"); + toggle.click(); + return checked; + }); + expect(before === "true", `activé par défaut : ${before}`); + await sleep(500); + await shoot("general-setting"); + await open(third); + const state = await lanes(); + expect(state.every((lane) => !lane.muted), JSON.stringify(state)); + return "activé par défaut ; désactivé, les 3 pistes jouent"; +}); + +await check("export d'un clip ouvert avec la piste 1 coupée par le réglage", async () => { + await nav("Réglages"); + await sleep(300); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Général")?.click()); + await sleep(300); + await page(() => [...document.querySelectorAll("[role=switch]")].find((item) => item.getAttribute("aria-label") === "Couper le Mix complet à la lecture").click()); + await sleep(500); + await open(third); + const state = await lanes(); + expect(state[0].muted, "piste 1 pas coupée"); + await page(() => [...document.querySelectorAll("button")].find((button) => button.textContent.trim() === "Exporter").click()); + await until("export terminé", "[...document.querySelectorAll('[role=status]')].some((item) => item.textContent.startsWith('Exporté'))", 60_000); + await sleep(1000); + const dir = join(root, "target", "test-config", "clips"); + const file = readdirSync(dir, { recursive: true }).find((name) => String(name).includes("_extrait")); + expect(file, "fichier exporté introuvable"); + const probe = JSON.parse( + execFileSync(join(root, "third_party", "ffmpeg", "bin", "ffprobe.exe"), [ + "-v", "error", "-select_streams", "a", "-show_entries", "stream=index:stream_tags", "-of", "json", join(dir, String(file)), + ]).toString(), + ); + const streams = probe.streams.map((stream) => stream.tags?.title ?? stream.tags?.handler_name ?? "?"); + expect(streams.length === 3 && streams[0] === "Mix complet", JSON.stringify(probe)); + return `${file} : ${streams.join(", ")}`; +}); + +close(); +const passed = results.filter(Boolean).length; +console.log(`${passed} sur ${results.length}`); +process.exit(passed === results.length ? 0 : 1);