diff --git a/src/Desktop/BiliCopilot.UI/Controls/Components/MomentCardControl/VideoMomentPresenter.xaml b/src/Desktop/BiliCopilot.UI/Controls/Components/MomentCardControl/VideoMomentPresenter.xaml index e29ff641..5c059b27 100644 --- a/src/Desktop/BiliCopilot.UI/Controls/Components/MomentCardControl/VideoMomentPresenter.xaml +++ b/src/Desktop/BiliCopilot.UI/Controls/Components/MomentCardControl/VideoMomentPresenter.xaml @@ -93,6 +93,23 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind ViewModel.VideoDuration, Mode=OneWay}" /> + /// Initializes a new instance of the class. /// - public VideoMomentPresenter() => InitializeComponent(); + public VideoMomentPresenter() + { + InitializeComponent(); + // Show/hide AddViewLaterButton on pointer enter/exit + this.PointerEntered += OnPointerEntered; + this.PointerExited += OnPointerExited; + this.Unloaded += OnUnloaded; + } + + private void OnPointerEntered(object? sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + { + if (AddViewLaterButton is not null) + { + AddViewLaterButton.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + } + } + + private void OnPointerExited(object? sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + { + if (AddViewLaterButton is not null) + { + AddViewLaterButton.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + } + } + + private void OnUnloaded(object? sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + this.PointerEntered -= OnPointerEntered; + this.PointerExited -= OnPointerExited; + this.Unloaded -= OnUnloaded; + } } diff --git a/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.cs b/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.cs index d5b0be93..e3f734c0 100644 --- a/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.cs +++ b/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.cs @@ -1,6 +1,5 @@ // Copyright (c) Bili Copilot. All rights reserved. -using BiliCopilot.UI.Models.Constants; using BiliCopilot.UI.Toolkits; using BiliCopilot.UI.ViewModels.Items; using Microsoft.UI.Xaml.Controls.Primitives; @@ -12,68 +11,155 @@ namespace BiliCopilot.UI.Controls.Components; /// /// 视频卡片控件. +/// 这个控件用于在列表/网格中显示视频项卡片,并处理与卡片交互相关的逻辑(播放、用户空间、右键菜单、稍后再看等)。 /// public sealed partial class VideoCardControl : LayoutControlBase { - private ButtonBase _rootCard; + // 根卡片元素(ControlTemplate 内的命名元素),用于监听指针进入/移出以显示操作按钮 + private UIElement _rootCard; + // 用户空间按钮(通常在卡片上显示作者/UP 主头像或按钮) private Button _userButton; + // “稍后再看”按钮(显示在封面上,悬停时可见) + private Button _addViewLaterButton; /// - /// Initializes a new instance of the class. + /// 构造函数:设置默认样式键。 /// public VideoCardControl() => DefaultStyleKey = typeof(VideoCardControl); /// + /// + /// 在模板应用时查找命名的子元素并绑定命令与事件。 + /// 每次模板重新应用前先取消上一次绑定,避免重复订阅。 + /// protected override void OnApplyTemplate() { - _rootCard = GetTemplateChild("RootCard") as ButtonBase; + // 取消之前订阅的事件,防止重复绑定 + if (_rootCard is not null) + { + _rootCard.PointerEntered -= OnRootPointerEntered; + _rootCard.PointerExited -= OnRootPointerExited; + if (_rootCard is Grid grid) + { + // 如果根元素是 Grid(例如 Moment 风格),也移除 Tapped 处理器 + grid.Tapped -= OnRootGridTapped; + } + } + + // 从 ControlTemplate 获取命名元素引用(允许为 null,需要做空检查) + _rootCard = GetTemplateChild("RootCard") as UIElement; _userButton = GetTemplateChild("UserButton") as Button; + _addViewLaterButton = GetTemplateChild("AddViewLaterButton") as Button; + + // 如果当前绑定了 ViewModel,则完成命令与事件的绑定 if (ViewModel is not null) { if (_rootCard is not null) { - _rootCard.Command = ViewModel.PlayCommand; + // 当指针进入/离开根卡片时,切换“稍后再看”按钮的可见性 + _rootCard.PointerEntered += OnRootPointerEntered; + _rootCard.PointerExited += OnRootPointerExited; + + // 根据不同模板类型决定如何处理点击或点击命令 + if (_rootCard is ButtonBase buttonBase) + { + // 若根元素是 ButtonBase(大部分卡片模板),则将播放命令绑定给它 + buttonBase.Command = ViewModel.PlayCommand; + } + else if (_rootCard is Grid momentGrid) + { + // 对于 Moment 风格,根元素可能是 Grid,需要监听 Tapped 事件以触发播放 + momentGrid.Tapped += OnRootGridTapped; + } } + // 将用户按钮绑定到 ViewModel 的显示用户空间命令 if (_userButton is not null) { _userButton.Command = ViewModel.ShowUserSpaceCommand; } + + // 将稍后再看按钮绑定到 ViewModel 的对应命令,初始为隐藏 + if (_addViewLaterButton is not null) + { + _addViewLaterButton.Command = ViewModel.AddToViewLaterCommand; + // 默认隐藏,靠指针进入/离开来控制可见性 + _addViewLaterButton.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + } } } /// + /// + /// 控件加载时注册上下文菜单请求事件 + /// protected override void OnControlLoaded() => ContextRequested += OnContextRequested; /// + /// + /// 控件卸载时移除事件订阅 + /// protected override void OnControlUnloaded() - => ContextRequested -= OnContextRequested; + { + ContextRequested -= OnContextRequested; + + if (_rootCard is not null) + { + _rootCard.PointerEntered -= OnRootPointerEntered; + _rootCard.PointerExited -= OnRootPointerExited; + if (_rootCard is Grid grid) + { + grid.Tapped -= OnRootGridTapped; + } + } + } /// + /// + /// 当 ViewModel 变更时,重新绑定命令到模板元素(如果存在)。 + /// 这样可以保证在复用控件或数据上下文切换时命令正确工作。 + /// protected override void OnViewModelChanged(VideoItemViewModel? oldValue, VideoItemViewModel? newValue) { - if (_rootCard is not null) + if (_rootCard is ButtonBase buttonBase) { - _rootCard.Command = newValue?.PlayCommand; + // 将根按钮的 Command 更新为新的 ViewModel 的播放命令(如果存在) + buttonBase.Command = newValue?.PlayCommand; } if (_userButton is not null) { + // 更新用户按钮的命令 _userButton.Command = newValue?.ShowUserSpaceCommand; } + + if (_addViewLaterButton is not null) + { + // 更新稍后再看按钮的命令 + _addViewLaterButton.Command = newValue?.AddToViewLaterCommand; + } } + /// + /// 创建“私密播放”菜单项(右键菜单) + /// private static MenuFlyoutItem CreatePrivatePlayItem() { return new MenuFlyoutItem() { + // 文本从本地化资源获取 Text = ResourceToolkit.GetLocalizedString(StringNames.PlayInPrivate), + // 使用 FluentIcons 显示图标 Icon = new FluentIcons.WinUI.SymbolIcon { Symbol = FluentIcons.Common.Symbol.EyeOff }, + // 使用 Tag 来标记应绑定到 ViewModel 的命令名,后续会根据 Tag 映射命令 Tag = nameof(ViewModel.PlayInPrivateCommand), }; } + /// + /// 创建“进入用户空间”菜单项 + /// private static MenuFlyoutItem CreateUserSpaceItem() { return new MenuFlyoutItem() @@ -84,6 +170,9 @@ private static MenuFlyoutItem CreateUserSpaceItem() }; } + /// + /// 创建“添加到稍后再看”菜单项 + /// private static MenuFlyoutItem CreateAddViewLaterItem() { return new MenuFlyoutItem() @@ -94,6 +183,9 @@ private static MenuFlyoutItem CreateAddViewLaterItem() }; } + /// + /// 创建“从稍后再看移除”菜单项(多用于已在稍后再看的列表中) + /// private MenuFlyoutItem CreateRemoveViewLaterItem() { return new MenuFlyoutItem() @@ -104,6 +196,9 @@ private MenuFlyoutItem CreateRemoveViewLaterItem() }; } + /// + /// 创建“从历史中移除”菜单项 + /// private MenuFlyoutItem CreateRemoveHistoryItem() { return new MenuFlyoutItem() @@ -114,11 +209,17 @@ private MenuFlyoutItem CreateRemoveHistoryItem() }; } + /// + /// 创建“从收藏中移除”菜单项 + /// private MenuFlyoutItem CreateRemoveFavoriteItem() { return new MenuFlyoutItem() { Text = ResourceToolkit.GetLocalizedString(StringNames.Remove), Icon = new FluentIcons.WinUI.SymbolIcon { Symbol = FluentIcons.Common.Symbol.Delete, Foreground = this.Get().GetThemeBrush("SystemFillColorCriticalBrush") }, Tag = nameof(ViewModel.RemoveFavoriteCommand) }; } + /// + /// 创建“在浏览器中打开”菜单项 + /// private static MenuFlyoutItem CreateOpenInBroswerItem() { return new MenuFlyoutItem() @@ -129,6 +230,9 @@ private static MenuFlyoutItem CreateOpenInBroswerItem() }; } + /// + /// 创建“复制视频链接”菜单项 + /// private static MenuFlyoutItem CreateCopyUriItem() { return new MenuFlyoutItem() @@ -139,6 +243,9 @@ private static MenuFlyoutItem CreateCopyUriItem() }; } + /// + /// 创建“固定内容/置顶”菜单项 + /// private static MenuFlyoutItem CreatePinItem() { return new MenuFlyoutItem() @@ -149,28 +256,39 @@ private static MenuFlyoutItem CreatePinItem() }; } + /// + /// 右键/触摸长按弹出上下文菜单的事件处理器 + /// private void OnContextRequested(UIElement sender, ContextRequestedEventArgs args) { if (ContextFlyout is null) { + // 首次请求时创建上下文菜单 CreateContextFlyout(); args.TryGetPosition(this, out var point); + // 在控件上显示菜单,指定位置 ContextFlyout.ShowAt(this, new Microsoft.UI.Xaml.Controls.Primitives.FlyoutShowOptions { Position = point }); } + // 将菜单项的 Tag 映射到 ViewModel 的命令 RelocateCommands(); args.Handled = true; } + /// + /// 创建完整的上下文菜单并根据当前 ViewModel 状态添加相应的菜单项 + /// private void CreateContextFlyout() { var menuFlyout = new MenuFlyout() { ShouldConstrainToRootBounds = false }; menuFlyout.Items.Add(CreatePrivatePlayItem()); + // 若不是 Moment 风格且用户有效,则添加进入用户空间菜单项 if (ViewModel.Style != VideoCardStyle.Moment && ViewModel.IsUserValid) { menuFlyout.Items.Add(CreateUserSpaceItem()); } + // 若不是在“稍后再看”列表中,则提供添加选项 if (ViewModel.Style != VideoCardStyle.ViewLater) { menuFlyout.Items.Add(CreateAddViewLaterItem()); @@ -179,6 +297,8 @@ private void CreateContextFlyout() menuFlyout.Items.Add(CreateOpenInBroswerItem()); menuFlyout.Items.Add(CreateCopyUriItem()); menuFlyout.Items.Add(CreatePinItem()); + + // 根据不同的列表风格,添加移除命令 if (ViewModel.Style == VideoCardStyle.ViewLater) { menuFlyout.Items.Add(CreateRemoveViewLaterItem()); @@ -195,6 +315,9 @@ private void CreateContextFlyout() ContextFlyout = menuFlyout; } + /// + /// 将菜单项的 Tag 映射到实际的 ViewModel 命令 + /// private void RelocateCommands() { if (ContextFlyout is not MenuFlyout flyout) @@ -204,6 +327,7 @@ private void RelocateCommands() foreach (var item in flyout.Items.OfType()) { + // 通过 Tag 的值来设置对应的 Command switch (item.Tag.ToString()) { case nameof(ViewModel.PlayInPrivateCommand): @@ -238,4 +362,55 @@ private void RelocateCommands() } } } + + /// + /// 指针进入根卡片时的处理:显示“稍后再看”按钮 + /// + private void OnRootPointerEntered(object sender, PointerRoutedEventArgs e) + { + if (_addViewLaterButton is not null) + { + _addViewLaterButton.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + } + } + + /// + /// 指针离开根卡片时的处理:隐藏“稍后再看”按钮 + /// + private void OnRootPointerExited(object sender, PointerRoutedEventArgs e) + { + if (_addViewLaterButton is not null) + { + _addViewLaterButton.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + } + } + + /// + /// 针对 Moment 风格(根元素为 Grid)处理 Tapped 事件的逻辑 + /// 需要在点击发生在“稍后再看”按钮上时阻止播放逻辑。 + /// + private void OnRootGridTapped(object sender, TappedRoutedEventArgs e) + { + // 如果 OriginalSource 在视觉树上属于 _addViewLaterButton,则不触发播放 + if (e.OriginalSource is DependencyObject source) + { + var element = source; + while (element != null) + { + if (element == _addViewLaterButton) + { + // 点击命中在“稍后再看”按钮,直接返回 + return; + } + + element = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(element); + } + } + + // 触发播放命令(如果可以执行) + if (ViewModel?.PlayCommand?.CanExecute(default) == true) + { + ViewModel.PlayCommand.Execute(default); + } + } } diff --git a/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.xaml b/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.xaml index 433d2f40..90b9569f 100644 --- a/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.xaml +++ b/src/Desktop/BiliCopilot.UI/Controls/Components/VideoCardControl/VideoCardControl.xaml @@ -48,6 +48,22 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=ViewModel.Duration}" /> + + + + + + + + + + +