Improve video info JSON viewer

This commit is contained in:
Emby Toolbox 2026-05-25 11:00:03 +05:00
parent 23f9ab3399
commit 9bf4588c54
3 changed files with 90 additions and 367 deletions

View File

@ -368,7 +368,6 @@
<TabItem Header="Подробная информация">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@ -412,37 +411,6 @@
</Grid>
<Border Grid.Row="1"
BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}"
BorderThickness="0,0,0,1"
Padding="0,0,0,8"
Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="126" Command="{Binding ExpandAllCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Развернуть всё" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="126" Command="{Binding CollapseAllCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Свернуть всё" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="132" Command="{Binding CopyJsonCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE8C8;" />
<TextBlock Text="Копировать JSON" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" MinWidth="130" Command="{Binding SaveJsonCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE74E;" />
<TextBlock Text="Сохранить JSON" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<Border Grid.Row="2"
Padding="8,4,8,8"
Background="{DynamicResource Ui.Brush.Surface}"
behaviors:VideoInfoDropTargetBehavior.IsEnabled="True">
@ -463,43 +431,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.Resources>
<Style x:Key="VideoInfoTreeItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="2,1" />
<Setter Property="Margin" Value="0,0,0,1" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<EventSetter Event="PreviewMouseRightButtonDown" Handler="OnJsonTreeItemPreviewMouseRightButtonDown" />
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Копировать значение"
Command="{Binding DataContext.VideoInfo.CopyNodeValueCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<MenuItem Header="Копировать узел"
Command="{Binding DataContext.VideoInfo.CopyNodeLineCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<MenuItem Header="Копировать узел с дочерними элементами"
Command="{Binding DataContext.VideoInfo.CopyNodeWithChildrenCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<Separator />
<MenuItem Header="Копировать путь к узлу"
Command="{Binding DataContext.VideoInfo.CopyNodePathCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
</ContextMenu>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
</Style.Triggers>
</Style>
</Grid.Resources>
<Grid Grid.RowSpan="2">
<Grid.Style>
@ -541,9 +472,21 @@
</TextBlock.Style>
</TextBlock>
<TreeView Grid.Row="1" ItemsSource="{Binding JsonNodes}" Margin="0" BorderThickness="0" Background="Transparent">
<TreeView.Style>
<Style TargetType="TreeView">
<ListBox Grid.Row="1"
ItemsSource="{Binding FormattedJsonLines}"
Margin="0"
BorderThickness="0"
Background="Transparent"
Foreground="{DynamicResource Ui.Brush.Text}"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
SelectionMode="Extended">
<ListBox.Style>
<Style TargetType="ListBox">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedFilePath}" Value="">
@ -551,36 +494,36 @@
</DataTrigger>
</Style.Triggers>
</Style>
</TreeView.Style>
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type viewModels:JsonTreeNodeViewModel}" ItemsSource="{Binding Children}">
<Border Background="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=Background}" Padding="2,1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" />
<TextBlock Text=": " />
<TextBlock Text="{Binding Value}" Foreground="{DynamicResource Ui.Brush.Muted}" />
</StackPanel>
<Border.Style>
<Style TargetType="Border">
</ListBox.Style>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowHover}" />
</Trigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=IsSelected}" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</DataTrigger>
</Style.Triggers>
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Focusable" Value="False" />
</Style>
</Border.Style>
</Border>
</HierarchicalDataTemplate>
</TreeView.Resources>
<TreeView.ItemContainerStyle>
<StaticResource ResourceKey="VideoInfoTreeItemStyle" />
</TreeView.ItemContainerStyle>
</TreeView>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBox Text="{Binding Mode=OneWay}"
BorderThickness="0"
Background="Transparent"
Foreground="{DynamicResource Ui.Brush.Text}"
FontFamily="Consolas"
FontSize="13"
IsReadOnly="True"
IsUndoEnabled="False"
IsInactiveSelectionHighlightEnabled="True"
AcceptsReturn="False"
AcceptsTab="False"
TextWrapping="NoWrap"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>

View File

@ -1,7 +1,4 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox;
@ -13,19 +10,4 @@ public partial class MainWindow
InitializeComponent();
DataContext = new MainWindowViewModel();
}
private void OnJsonTreeItemPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
var dependencyObject = e.OriginalSource as DependencyObject;
while (dependencyObject is not null && dependencyObject is not TreeViewItem)
{
dependencyObject = VisualTreeHelper.GetParent(dependencyObject);
}
if (dependencyObject is TreeViewItem item)
{
item.IsSelected = true;
item.Focus();
}
}
}

View File

@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
@ -23,7 +22,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private string _selectedFilePath = string.Empty;
private string _analysisStateText = string.Empty;
private string _errorMessage = string.Empty;
private string _rawJson = string.Empty;
private string _formattedJson = string.Empty;
private IReadOnlyList<string> _formattedJsonLines = Array.Empty<string>();
private string _summaryText = string.Empty;
private bool _isBusy;
private bool _isVideoInfoDropHighlight;
@ -44,41 +44,16 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
SelectFileCommand = new RelayCommand(ExecuteSelectFile);
SelectSummaryFilesCommand = new RelayCommand(ExecuteSelectSummaryFiles);
ExpandAllCommand = new RelayCommand(ExecuteExpandAll);
CollapseAllCommand = new RelayCommand(ExecuteCollapseAll);
CopyJsonCommand = new RelayCommand(ExecuteCopyJson, () => !string.IsNullOrWhiteSpace(_rawJson));
SaveJsonCommand = new RelayCommand(ExecuteSaveJson, () => !string.IsNullOrWhiteSpace(_rawJson));
CopySummaryCommand = new RelayCommand(ExecuteCopySummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
SaveSummaryCommand = new RelayCommand(ExecuteSaveSummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
CopyNodeValueCommand = new RelayCommand(ExecuteCopyNodeValue, CanCopyNodeValue);
CopyNodeLineCommand = new RelayCommand(ExecuteCopyNodeLine, CanCopyNodeLine);
CopyNodeWithChildrenCommand = new RelayCommand(ExecuteCopyNodeWithChildren, CanCopyNodeWithChildren);
CopyNodePathCommand = new RelayCommand(ExecuteCopyNodePath, CanCopyNodePath);
}
public ObservableCollection<JsonTreeNodeViewModel> JsonNodes { get; } = new();
public ICommand SelectFileCommand { get; }
public ICommand SelectSummaryFilesCommand { get; }
public ICommand ExpandAllCommand { get; }
public ICommand CollapseAllCommand { get; }
public ICommand CopyJsonCommand { get; }
public ICommand SaveJsonCommand { get; }
public ICommand CopySummaryCommand { get; }
public ICommand SaveSummaryCommand { get; }
public ICommand CopyNodeValueCommand { get; }
public ICommand CopyNodeLineCommand { get; }
public ICommand CopyNodeWithChildrenCommand { get; }
public ICommand CopyNodePathCommand { get; }
public string SelectedFilePath
{
get => _selectedFilePath;
@ -124,6 +99,37 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
}
}
public string FormattedJson
{
get => _formattedJson;
private set
{
if (_formattedJson == value)
{
return;
}
_formattedJson = value;
FormattedJsonLines = SplitTextLines(value);
OnPropertyChanged();
}
}
public IReadOnlyList<string> FormattedJsonLines
{
get => _formattedJsonLines;
private set
{
if (ReferenceEquals(_formattedJsonLines, value))
{
return;
}
_formattedJsonLines = value;
OnPropertyChanged();
}
}
public bool IsBusy
{
get => _isBusy;
@ -307,9 +313,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private async Task AnalyzeAsync()
{
JsonNodes.Clear();
ErrorMessage = string.Empty;
_rawJson = string.Empty;
FormattedJson = string.Empty;
RaiseCommandStates();
if (string.IsNullOrWhiteSpace(SelectedFilePath))
@ -335,8 +340,7 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
try
{
_rawJson = result.Json;
BuildTree(_rawJson);
FormattedJson = FormatJsonOrFallback(result.Json);
AnalysisStateText = "Готово";
RaiseCommandStates();
_logging.Info($"ffprobe завершен: {Path.GetFileName(SelectedFilePath)}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr);
@ -427,125 +431,6 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
RaiseCommandStates();
}
private void BuildTree(string json)
{
using var doc = JsonDocument.Parse(json);
JsonNodes.Clear();
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in doc.RootElement.EnumerateObject())
{
JsonNodes.Add(CreateNode(property.Name, property.Value, null));
}
}
else
{
JsonNodes.Add(CreateNode("root", doc.RootElement, null));
}
}
private static JsonTreeNodeViewModel CreateNode(string name, JsonElement element, JsonTreeNodeViewModel? parent)
{
var node = new JsonTreeNodeViewModel(name, GetPreviewValue(element), element.GetRawText(), parent);
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in element.EnumerateObject())
{
node.Children.Add(CreateNode(prop.Name, prop.Value, node));
}
break;
case JsonValueKind.Array:
var index = 0;
foreach (var item in element.EnumerateArray())
{
node.Children.Add(CreateNode($"[{index}]", item, node));
index++;
}
break;
}
return node;
}
private static string GetPreviewValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => "{...}",
JsonValueKind.Array => "[...]",
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => element.GetRawText()
};
}
private void ExecuteExpandAll()
{
SetExpandedState(JsonNodes, true);
}
private void ExecuteCollapseAll()
{
SetExpandedState(JsonNodes, false);
}
private static void SetExpandedState(IEnumerable<JsonTreeNodeViewModel> nodes, bool isExpanded)
{
foreach (var node in nodes)
{
node.IsExpanded = isExpanded;
SetExpandedState(node.Children, isExpanded);
}
}
private void ExecuteCopyJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
Clipboard.SetText(_rawJson);
_logging.Info("JSON скопирован в буфер обмена", "video-info.copy");
}
private void ExecuteSaveJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath)
? "ffprobe.json"
: $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.ffprobe.json";
var dialog = new SaveFileDialog
{
Title = "Сохранить JSON ffprobe",
Filter = "JSON (*.json)|*.json|Все файлы|*.*",
FileName = defaultName,
InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder),
};
if (dialog.ShowDialog() != true)
{
return;
}
File.WriteAllText(dialog.FileName, _rawJson);
_recentPaths.RememberChosenFolder(
RecentPathScenario.SettingsOutputFolder,
Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName);
_logging.Info($"JSON сохранен: {dialog.FileName}", "video-info.save");
}
private void ExecuteCopySummary()
{
if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован")
@ -594,105 +479,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private void RaiseCommandStates()
{
(CopyJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopySummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveSummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeValueCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeLineCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeWithChildrenCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodePathCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private static JsonTreeNodeViewModel? AsNode(object? parameter)
{
return parameter as JsonTreeNodeViewModel;
}
private bool CanCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
return node is not null && node.Children.Count == 0;
}
private bool CanCopyNodeLine(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodeWithChildren(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodePath(object? parameter)
{
return AsNode(parameter) is not null;
}
private void ExecuteCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
if (node is null || node.Children.Count > 0)
{
return;
}
Clipboard.SetText(node.Value);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeLine(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText($"{node.Name}: {node.Value}");
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeWithChildren(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
var formatted = FormatJsonOrFallback(node.SubtreeJson);
Clipboard.SetText(formatted);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodePath(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText(BuildNodePath(node));
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private static string BuildNodePath(JsonTreeNodeViewModel node)
{
if (node.Parent is null)
{
return node.Name;
}
var parentPath = BuildNodePath(node.Parent);
if (node.Name.StartsWith("[", StringComparison.Ordinal))
{
return $"{parentPath}{node.Name}";
}
return string.IsNullOrWhiteSpace(parentPath) ? node.Name : $"{parentPath}.{node.Name}";
}
private static string FormatJsonOrFallback(string json)
@ -707,5 +495,15 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
return json;
}
}
private static IReadOnlyList<string> SplitTextLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return Array.Empty<string>();
}
return text.ReplaceLineEndings("\n").Split('\n');
}
}