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
23 changes: 23 additions & 0 deletions docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,29 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on
for the rest. The encoders are looked up with `ffmpeg -encoders` once per FFmpeg, so a copy the user picked without
x265 or AV1 fails with a clear message instead of an FFmpeg error. x265 and SVT-AV1 log only errors
(`-x265-params log-level=error`, `SVT_LOG=1`).
- **Hardware encoding** (*Hardware acceleration* in the video card, `MediaOptions.HardwareEncoding`; off by default and
for older presets, so nobody's results change without asking). H.264 and HEVC encodes (not VP9, AV1 and the
MPEG-style codecs, not stream copies, not pictures → video) go to the graphics chip (`Media/HardwareVideo.cs`,
`Media/HardwareProbe.cs`):

| System | Tried in this order |
|---|---|
| Windows | `*_nvenc` (NVIDIA), `*_qsv` (Intel Quick Sync), `*_amf` (AMD) |
| macOS | `*_videotoolbox` with constant quality (Apple Silicon), then with a bitrate (Intel Macs reject `-q:v`) |
| Linux | `*_nvenc`, then `*_vaapi` on `/dev/dri/renderD128` (`-vaapi_device`, frames uploaded with `format=nv12,hwupload`) |

FFmpeg lists encoders the computer can't run (NVENC without an NVIDIA card), so only the ones `ffmpeg -encoders`
lists are tried, each first with a test encode with the real options (0.1 s of `testsrc` at 256 × 144 to the null
muxer, 15 s limit). The first that succeeds is used; results are kept per FFmpeg executable, encoder, rate control
and bit depth until Filee restarts, and written to the log. Quality: *High/Balanced/Small* become quantizer 19/24/29
for H.264 and 23/27/31 for HEVC (one above x264's and x265's CRF: hardware encoders spend bits less wisely) as NVENC
`-rc vbr -cq N -b:v 0 -preset p5`, Quick Sync `-global_quality N -preset medium` (ICQ), AMF `-rc cqp -qp_p N -qp_i N−2`
(`-quality balanced`) and VA-API `-rc_mode CQP -qp N`; VideoToolbox gets `-q:v` 70/60/50, or on Intel Macs
`-b:v` from 0.14/0.09/0.05 bits per pixel per frame (× 0.65 for HEVC). 8-bit video is NV12 (H.264 High, HEVC Main);
10-bit and HDR sources stay P010 HEVC Main 10 with their colour tags when the encoder passes a 10-bit test encode,
otherwise x265 encodes them. HEVC keeps the `hvc1` tag in MP4/M4V/MOV. When a hardware encode fails anyway (a
picture too large for the chip, a driver error), the partial output is deleted and the file is encoded again with
x264/x265; that is logged, not shown as a failure.
- DSD (DSF, DFF) decodes at 2.8 MHz or more: lossless and PCM targets get 88.2 kHz, Vorbis and WMA 44.1 kHz, the
other lossy encoders their nearest supported rate.
- Subtitle extraction takes the first text subtitle track (SubRip, ASS/SSA, WebVTT, MP4 timed text, CEA-608, …).
Expand Down
55 changes: 49 additions & 6 deletions src/Filee.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Filee.App.Controls;
using Filee.App.Services;
using Filee.App.Services.Triggers;
using Filee.Core.Conversion;
Expand All @@ -22,7 +23,14 @@ public partial class App : Application
private IDisposable? _convertDebounce;
private bool _selectionPending;

public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <summary>Forwarded launches and empty selections can come in bursts: a few lines a minute are enough.</summary>
private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30));

public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
SingleSelectionGuard.Register();
}

public override void OnFrameworkInitializationCompleted()
{
Expand All @@ -34,6 +42,9 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}

/// <summary>Ends Filee as the tray's Quit does.</summary>
public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown();

private void Start(IClassicDesktopStyleApplicationLifetime desktop)
{
// macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while
Expand All @@ -42,12 +53,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
Filee.Platform.MacOS.MacOSServicesProvider.Register(files =>
Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files]))));
var log = AppHost.Get<ILogger<App>>();
// Exceptions in UI event handlers end Filee; write them down first (they are not marked handled).
Dispatcher.UIThread.UnhandledException += (_, e) =>
log.LogCritical(e.Exception, "Unhandled exception on the UI thread");
var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get<ILogger<UiWatchdog>>());
watchdog.Start();
var store = AppHost.Get<UserDataStore>();
store.Load();

var loc = AppHost.Get<LocalizationService>();
loc.Attach(this);
ApplySettings(store.Settings);
// Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts
// every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile.
_ = AppHost.Get<ConverterCatalog>().CheckInBackgroundAsync();

// Global gestures → donut toolbar
var triggers = AppHost.Get<TriggerService>();
Expand All @@ -66,6 +85,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection();
if (files.Count > 0 && !store.Settings.Paused)
radial.ShowForFiles(e.X, e.Y, files);
else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held))
log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}",
platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window",
Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "",
LogThrottle.HeldText(held));
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Expand Down Expand Up @@ -104,20 +128,34 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)

// Later launches (context menu, Send To, double-clicking the exe) forward their arguments here.
if (Program.Instance is { } instance)
{
instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args)));
instance.StartListening();
}
ListenForLaterLaunches(instance, log);
Program.InstanceAcquired += again => ListenForLaterLaunches(again, log);

desktop.Exit += (_, _) =>
{
watchdog.Dispose();
AppHost.Get<TriggerService>().Dispose();
AppHost.Get<TrayService>().Dispose();
AppHost.Get<WatchFolderService>().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5));
};

HandleCommandLine(Program.Options, firstLaunch: true);
WatchForUpdates(store);
// Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it):
// say so once, next to the tray, and on the Home page for as long as it lasts.
if (AppHost.Get<ElevationService>().ShellCannotReachFilee)
DispatcherTimer.RunOnce(() => AppHost.Get<WindowService>().ShowElevationNotice(), TimeSpan.FromSeconds(2));
}

private void ListenForLaterLaunches(SingleInstance instance, ILogger log)
{
instance.ArgumentsReceived += args =>
{
if (Throttle.TryEnter("forwarded", out var held))
log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held));
Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true));
};
instance.StartListening();
}

/// <summary>
Expand Down Expand Up @@ -193,8 +231,13 @@ private static void ApplySettings(AppSettings settings)
AppHost.Get<SystemIntegrationService>().Apply(settings);
}

private void HandleCommandLine(CommandLine options, bool firstLaunch = false)
private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false)
{
// Pairs with "A later launch handed over …": the request reached the UI thread too.
if (forwarded && Throttle.TryEnter("forwarded-handled", out var held))
AppHost.Get<ILogger<App>>().LogInformation("Handling a later launch: {Request}{Held}",
options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee",
LogThrottle.HeldText(held));
if (options.Quit)
{
// The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this).
Expand Down
9 changes: 9 additions & 0 deletions src/Filee.App/Assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
"home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.",
"home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.",
"home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.",
"home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.",
"home.elevated_restart": "Restart without administrator rights",
"home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.",
"elevation.notice_title": "Filee is running as administrator",
"elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.",
"startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.",
"startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.",
"home.recent": "Recent conversions",
"home.recent_empty": "Your converted files will show up here.",
"home.open_folder": "Open folder",
Expand Down Expand Up @@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (smaller)",
"presets.video_codec.av1": "AV1 (smallest, slow)",
"presets.hardware_encoding": "Use hardware acceleration (graphics chip)",
"presets.hardware_encoding_hint": "Encodes H.264 and HEVC on the graphics chip.\nMuch faster and uses less power.\nSlightly lower quality at the same file size.\nWithout a usable chip, the video is encoded as usual.",
"presets.audio_codec": "Audio codec",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (lossless)",
Expand Down
9 changes: 9 additions & 0 deletions src/Filee.App/Assets/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
"home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.",
"home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.",
"home.elevated_restart": "관리자 권한 없이 다시 시작",
"home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
"elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요",
"elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.",
"startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
"startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.",
"home.recent": "최근 변환",
"home.recent_empty": "변환한 파일이 여기에 표시됩니다.",
"home.open_folder": "폴더 열기",
Expand Down Expand Up @@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (더 작게)",
"presets.video_codec.av1": "AV1 (가장 작게, 느림)",
"presets.hardware_encoding": "하드웨어 가속 사용 (그래픽 칩)",
"presets.hardware_encoding_hint": "H.264와 HEVC를 그래픽 칩으로 인코딩해요.\n훨씬 빠르고 전력도 덜 써요.\n같은 용량이면 화질이 조금 낮아요.\n쓸 수 있는 칩이 없으면 평소처럼 인코딩해요.",
"presets.audio_codec": "오디오 코덱",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (무손실)",
Expand Down
9 changes: 9 additions & 0 deletions src/Filee.App/Assets/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
"home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。",
"home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。",
"home.elevated_restart": "以非管理员身份重新启动",
"home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。",
"elevation.notice_title": "Filee 正以管理员身份运行",
"elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。",
"startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。",
"startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。",
"home.recent": "最近的转换",
"home.recent_empty": "转换后的文件会显示在这里。",
"home.open_folder": "打开文件夹",
Expand Down Expand Up @@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265(更小)",
"presets.video_codec.av1": "AV1(最小,较慢)",
"presets.hardware_encoding": "使用硬件加速(显卡)",
"presets.hardware_encoding_hint": "用显卡编码 H.264 和 HEVC。\n速度快得多,也更省电。\n相同文件大小时画质略低。\n没有可用的显卡时照常编码。",
"presets.audio_codec": "音频编码",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC(无损)",
Expand Down
76 changes: 76 additions & 0 deletions src/Filee.App/Controls/SingleSelectionGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Keeps combo boxes and single-selection lists from losing their selection to the toggle modifier. Avalonia treats
// Ctrl+Space, Ctrl+Enter and Ctrl+click (Cmd on macOS) on the selected item as "toggle", which leaves a combo box
// empty and pushes null into a setting that always has a value (#107). Multi-selection lists keep the toggle.
//
// A selection cleared some other way is refused by the view models (Choices.Restore); the control then shows the
// bound value again.

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;

namespace Filee.App.Controls;

public static class SingleSelectionGuard
{
private static bool _registered;

/// <summary>Installs the class handlers once for every combo box and list box of the application.</summary>
public static void Register()
{
if (_registered)
return;
_registered = true;
// Tunnel: the item's own handlers (which toggle) run in the bubble phase.
InputElement.KeyDownEvent.AddClassHandler<ListBoxItem>(OnKeyDown, RoutingStrategies.Tunnel);
InputElement.PointerPressedEvent.AddClassHandler<ListBoxItem>(OnPointer, RoutingStrategies.Tunnel);
InputElement.PointerReleasedEvent.AddClassHandler<ListBoxItem>(OnPointer, RoutingStrategies.Tunnel);
SelectingItemsControl.SelectedItemProperty.Changed.AddClassHandler<SelectingItemsControl>(OnSelectedItemChanged);
}

/// <summary>
/// The view model puts its value back while the control is still clearing its selection, which the control misses:
/// once it is done, it reads the bound value again (still null when the view model took the null).
/// </summary>
private static void OnSelectedItemChanged(SelectingItemsControl control, AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is null && e.OldValue is not null && control is ComboBox or ListBox
&& BindingOperations.GetBindingExpressionBase(control, SelectingItemsControl.SelectedItemProperty) is { } binding)
Dispatcher.UIThread.Post(binding.UpdateTarget);
}

private static void OnKeyDown(ListBoxItem item, KeyEventArgs e)
{
if (ItemSelectionEventTriggers.ShouldTriggerSelection(item, e) && WouldDeselect(item, e))
Keep(item, e);
}

private static void OnPointer(ListBoxItem item, PointerEventArgs e)
{
if (e.GetCurrentPoint(item).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed or PointerUpdateKind.LeftButtonReleased
&& WouldDeselect(item, e))
Keep(item, e);
}

/// <summary>The selected item of a single-selection control, picked again with the toggle modifier held.</summary>
private static bool WouldDeselect(ListBoxItem item, RoutedEventArgs e) =>
!e.Handled && item.IsSelected && ItemSelectionEventTriggers.HasToggleSelectionModifier(item, e)
&& ItemsControl.ItemsControlFromItemContainer(item) switch
{
ComboBox => true,
ListBox list => (list.SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0,
_ => false,
};

/// <summary>Picking the item that is already selected: nothing changes, an open combo box closes as after any pick.</summary>
private static void Keep(ListBoxItem item, RoutedEventArgs e)
{
e.Handled = true;
if (e.RoutedEvent != InputElement.PointerPressedEvent && ItemsControl.ItemsControlFromItemContainer(item) is ComboBox combo)
combo.SetCurrentValue(ComboBox.IsDropDownOpenProperty, false);
}
}
Loading
Loading