Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion app/src-tauri/src/clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrackMix>,
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";
Expand Down Expand Up @@ -180,6 +206,22 @@ impl EngineSlot {
self.with_library(|library| library.games())
}

pub fn clip_mix(&self, path: &str) -> Result<Option<ClipMix>, 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))
Expand Down Expand Up @@ -516,6 +558,19 @@ fn percent_decode(text: &str) -> Option<String> {
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!(
Expand Down
30 changes: 29 additions & 1 deletion app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -322,6 +333,23 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result<Vec<String>, Loca
slot.clip_games()
}

#[tauri::command]
pub async fn clip_mix(
slot: State<'_, EngineSlot>,
path: String,
) -> Result<Option<ClipMix>, 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>,
Expand Down
11 changes: 11 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -749,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,
Expand All @@ -769,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,
Expand Down
17 changes: 17 additions & 0 deletions app/src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
62 changes: 46 additions & 16 deletions app/src/components/gallery/Player.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -97,7 +97,9 @@ export function Player({
const [range, setRange] = useState<Range>({ start: 0, end: clip.duration_secs });
const [tracks, setTracks] = useState<TrackFile[] | null>(null);
const [tracksFailed, setTracksFailed] = useState<string | null>(null);
const [mix, setMix] = useState<TrackMix[]>([]);
const [mixState, setMixState] = useState<ClipMix>({ tracks: [], full_mix_chosen: false });
const [muteFullMix, setMuteFullMix] = useState(false);
const pendingMix = useRef<{ timer: number; state: ClipMix } | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const fps = clip.fps;
Expand All @@ -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)),
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<div ref={root} onKeyDown={keys} className="player flex min-h-0 flex-1 flex-col">
Expand Down Expand Up @@ -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}
/>
))}
Expand All @@ -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}
/>
Expand Down
Loading
Loading