Skip to content
Open
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
40 changes: 40 additions & 0 deletions QuickLook.Plugin/QuickLook.Plugin.VideoViewer/Styles.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,46 @@
</Style.Triggers>
</Style>

<Style x:Key="SpeedButtonStyle"
BasedOn="{StaticResource CaptionButtonBaseStyle}"
TargetType="Button">
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="22" />
<Setter Property="Margin" Value="2,0,0,0" />
<Setter Property="Padding" Value="3,0" />
<Setter Property="Background" Value="{DynamicResource CardBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource CaptionButtonIconForeground}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource CardBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{DynamicResource CardBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
</Trigger>
</Style.Triggers>
</Style>

<!-- Postion Slider Control -->
<ControlTemplate x:Key="PositionSliderThumbTemplate" TargetType="{x:Type Thumb}">
<Grid HorizontalAlignment="Center"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<BTN_PlayPause>Play/Pause</BTN_PlayPause>
<BTN_Loop>Loop</BTN_Loop>
<BTN_HardwareAcceleration>Hardware/Software Decoding</BTN_HardwareAcceleration>
<BTN_Speed>Playback Speed (click to cycle, right-click to reset; +/- and 0 also work)</BTN_Speed>
<BTN_Volume>Volume</BTN_Volume>
<BTN_Time>Time Elapsed/Remaining</BTN_Time>
</en>
Expand Down Expand Up @@ -124,6 +125,7 @@
<BTN_PlayPause>Play/Pause</BTN_PlayPause>
<BTN_Loop>Loop</BTN_Loop>
<BTN_HardwareAcceleration>Hardware/Software Decoding</BTN_HardwareAcceleration>
<BTN_Speed>Скорость воспроизведения (клик — переключить, ПКМ — сбросить; также +/- и 0)</BTN_Speed>
<BTN_Volume>Volume</BTN_Volume>
<BTN_Time>Time Elapsed/Remaining</BTN_Time>
</ru-RU>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@
</TextBlock.Style>
</TextBlock>
</Button>
<Button x:Name="buttonSpeed"
DockPanel.Dock="Left"
Style="{StaticResource SpeedButtonStyle}"
ToolTip="Playback Speed (+/- to change, 0 to reset)">
<TextBlock VerticalAlignment="Center"
Text="{Binding PlaybackSpeed, ElementName=viewerPanel, StringFormat={}{0:0.##}x}" />
</Button>
<Button x:Name="buttonMute"
DockPanel.Dock="Right"
ToolTip="Volume">
Expand Down
165 changes: 165 additions & 0 deletions QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ public partial class ViewerPanel : UserControl, IDisposable, INotifyPropertyChan
private bool _wasPlaying;
private bool _shouldLoop;
private bool _useHardwareAcceleration;
private double _playbackSpeed = 1.0d;

// Preset playback speeds cycled through by the speed button and the +/- hotkeys.
private static readonly double[] SpeedPresets = [0.25d, 0.5d, 0.75d, 1.0d, 1.25d, 1.5d, 1.75d, 2.0d];

// Seek step sizes (in 100ns ticks, same unit as MediaPosition/MediaDuration) used by the
// timeline hotkeys: Shift+Left/Right for a short seek, Ctrl+Left/Right for a long seek.
// Plain arrow keys are intentionally left untouched: QuickLook's global hotkey dispatcher
// already uses them to switch between files in Explorer.
private static readonly long ShortSeekTicks = TimeSpan.FromSeconds(5).Ticks;
private static readonly long LongSeekTicks = TimeSpan.FromSeconds(30).Ticks;

public ViewerPanel(ContextObject context)
{
Expand Down Expand Up @@ -88,12 +99,19 @@ public ViewerPanel(ContextObject context)
buttonPlayPause.ToolTip = TranslationHelper.Get("BTN_PlayPause", translationFile, failsafe: "Play/Pause");
buttonLoop.ToolTip = TranslationHelper.Get("BTN_Loop", translationFile, failsafe: "Loop");
buttonHardwareAcceleration.ToolTip = TranslationHelper.Get("BTN_HardwareAcceleration", translationFile, failsafe: "Hardware/Software Decoding");
buttonSpeed.ToolTip = TranslationHelper.Get("BTN_Speed", translationFile, failsafe: "Playback Speed (+/- to change, 0 to reset)");
buttonMute.ToolTip = TranslationHelper.Get("BTN_Volume", translationFile, failsafe: "Volume");
buttonTime.ToolTip = TranslationHelper.Get("BTN_Time", translationFile, failsafe: "Time Elapsed/Remaining");

buttonPlayPause.Click += TogglePlayPause;
buttonLoop.Click += ToggleShouldLoop;
buttonHardwareAcceleration.Click += ToggleHardwareAcceleration;
buttonSpeed.Click += (_, _) => CycleSpeed(1);
buttonSpeed.MouseRightButtonUp += (_, e) =>
{
PlaybackSpeed = 1.0d;
e.Handled = true;
};
buttonTime.Click += (_, _) => buttonTime.Tag = (string)buttonTime.Tag == "Time" ? "Length" : "Time";
buttonMute.Click += (_, _) => volumeSliderLayer.Visibility = Visibility.Visible;
volumeSliderLayer.MouseDown += (_, _) => volumeSliderLayer.Visibility = Visibility.Collapsed;
Expand All @@ -109,6 +127,14 @@ public ViewerPanel(ContextObject context)
};

PreviewMouseWheel += (_, e) => ChangeVolume(e.Delta / 120d * 0.04d);

// Keyboard hotkeys for seeking the timeline and changing playback speed.
// Keep keyboard focus on the panel itself: every child control in this panel
// (buttons, sliders) is Focusable="False" by design, so focus otherwise stays
// on the host window and PreviewKeyDown here would not fire on it.
Focusable = true;
Loaded += (_, _) => Focus();
PreviewKeyDown += ViewerPanel_PreviewKeyDown;
}

private partial void LoadAndInsertGlassLayer();
Expand Down Expand Up @@ -157,6 +183,27 @@ private set
}
}

/// <summary>
/// The current playback speed multiplier (1.0 = normal speed). Backed by the
/// underlying <see cref="mediaElement"/>'s DirectShow SpeedRatio (IMediaSeeking.SetRate).
/// </summary>
public double PlaybackSpeed
{
get => _playbackSpeed;
set
{
var clamped = Math.Max(SpeedPresets[0], Math.Min(SpeedPresets[SpeedPresets.Length - 1], value));
if (Math.Abs(clamped - _playbackSpeed) < 0.0001d) return;

_playbackSpeed = clamped;

if (mediaElement != null)
mediaElement.SpeedRatio = _playbackSpeed;

OnPropertyChanged();
}
}

public BitmapSource CoverArt
{
get => _coverArt;
Expand All @@ -175,6 +222,7 @@ public void Dispose()
SettingHelper.Set("VolumeDouble", LinearVolume, "QuickLook.Plugin.VideoViewer");
SettingHelper.Set("ShouldLoop", ShouldLoop, "QuickLook.Plugin.VideoViewer");
SettingHelper.Set("UseHardwareAcceleration", UseHardwareAcceleration, "QuickLook.Plugin.VideoViewer");
SettingHelper.Set("PlaybackSpeed", PlaybackSpeed, "QuickLook.Plugin.VideoViewer");

try
{
Expand All @@ -200,6 +248,10 @@ public void Dispose()

private void Panel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// Make sure the panel (not the window) owns keyboard focus, so that the
// seek/speed hotkeys keep working after the user interacts with the mouse.
Focus();

if (e.LeftButton == MouseButtonState.Pressed)
{
var wnd = Window.GetWindow(this);
Expand All @@ -211,6 +263,118 @@ private void Panel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
}
}

/// <summary>
/// Handles keyboard shortcuts for timeline seeking and playback speed.
///
/// Plain Left/Right/Up/Down are deliberately NOT used here: QuickLook's global
/// low-level keyboard hook (see QuickLook.KeystrokeDispatcher) already reuses those
/// keys system-wide to switch between files in Explorer, and it never marks the
/// keystroke as handled, so it would still reach this handler too. Using modifier
/// combinations avoids fighting over the same keys:
/// Shift+Left / Shift+Right - seek 5 seconds backward/forward
/// Ctrl+Left / Ctrl+Right - seek 30 seconds backward/forward
/// Home / End - jump to the start/end of the media
/// +/- (OemPlus/OemMinus) - increase/decrease playback speed
/// 0 (D0/NumPad0) - reset playback speed to 1.0x
/// </summary>
private void ViewerPanel_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (mediaElement?.Source == null)
return;

var modifiers = Keyboard.Modifiers;

switch (e.Key)
{
case Key.Left when modifiers == ModifierKeys.Shift:
Seek(-ShortSeekTicks);
e.Handled = true;
break;

case Key.Right when modifiers == ModifierKeys.Shift:
Seek(ShortSeekTicks);
e.Handled = true;
break;

case Key.Left when modifiers == ModifierKeys.Control:
Seek(-LongSeekTicks);
e.Handled = true;
break;

case Key.Right when modifiers == ModifierKeys.Control:
Seek(LongSeekTicks);
e.Handled = true;
break;

case Key.Home when modifiers == ModifierKeys.None:
SeekTo(0L);
e.Handled = true;
break;

case Key.End when modifiers == ModifierKeys.None:
SeekTo(mediaElement.MediaDuration);
e.Handled = true;
Comment on lines +315 to +316

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Seeking to exactly MediaDuration triggers the existing MediaEnded handler, which immediately resets MediaPosition to 0L; consequently, the End hotkey does not leave playback at the end of the video.

Triggers: When End is pressed on a loaded video and the media backend raises MediaEnded after positioning at its duration.

Suggested fix: Seek to the last valid position before the duration, or suppress the end-reset behavior for an explicit end seek.

break;

case Key.OemPlus or Key.Add when modifiers == ModifierKeys.None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The + playback-speed shortcut does not work on a standard keyboard because + is produced as Shift+OemPlus, while this branch only matches OemPlus when ModifierKeys.None is active.

Triggers: When the user presses the standard keyboard + key rather than NumPad Add.

Suggested fix: Accept ModifierKeys.Shift for Key.OemPlus (while retaining the unmodified Key.Add case for the numeric keypad).

Suggested change
case Key.OemPlus or Key.Add when modifiers == ModifierKeys.None:
case Key.OemPlus when modifiers == ModifierKeys.Shift:
case Key.Add when modifiers == ModifierKeys.None:

CycleSpeed(1);
e.Handled = true;
break;

case Key.OemMinus or Key.Subtract when modifiers == ModifierKeys.None:
CycleSpeed(-1);
e.Handled = true;
break;

case Key.D0 or Key.NumPad0 when modifiers == ModifierKeys.None:
PlaybackSpeed = 1.0d;
e.Handled = true;
break;
}
}

/// <summary>Seeks the timeline by a relative amount of 100ns ticks (same unit as MediaPosition).</summary>
private void Seek(long deltaTicks)
{
if (mediaElement == null)
return;

SeekTo(mediaElement.MediaPosition + deltaTicks);
}

/// <summary>Seeks the timeline to an absolute position, clamped to the media's duration.</summary>
private void SeekTo(long positionTicks)
{
if (mediaElement == null)
return;

var duration = mediaElement.MediaDuration;
var clamped = duration > 0
? Math.Max(0L, Math.Min(duration, positionTicks))
: Math.Max(0L, positionTicks);

mediaElement.MediaPosition = clamped;
}

/// <summary>Moves the playback speed to the next/previous preset in <see cref="SpeedPresets"/>.</summary>
private void CycleSpeed(int direction)
{
var index = 3; // default: land on 1.0x if the current speed isn't an exact preset match
var smallestDelta = double.MaxValue;
for (var i = 0; i < SpeedPresets.Length; i++)
{
var delta = Math.Abs(SpeedPresets[i] - PlaybackSpeed);
if (delta < smallestDelta)
{
smallestDelta = delta;
index = i;
}
}

index = Math.Max(0, Math.Min(SpeedPresets.Length - 1, index + direction));
PlaybackSpeed = SpeedPresets[index];
}

public event PropertyChangedEventHandler PropertyChanged;

private void MediaOpened(object o, RoutedEventArgs args)
Expand Down Expand Up @@ -483,6 +647,7 @@ public void LoadAndPlay(string path, MediaInfoNative info)
mediaElement.Source = new Uri(path);
// old plugin use an int-typed "Volume" config key ranged from 0 to 100. Let's use a new one here.
LinearVolume = Math.Max(0d, Math.Min(1d, SettingHelper.Get("VolumeDouble", 1d, "QuickLook.Plugin.VideoViewer")));
PlaybackSpeed = SettingHelper.Get("PlaybackSpeed", 1d, "QuickLook.Plugin.VideoViewer");

mediaElement.Play();
}
Expand Down