【WPF】MediaElement＋MediaTimelineを用いて音声・動画をシークバーに同期しつつ制御する。 

簡単なメディア再生機能を実装する機会があったため、備忘録としてまとめました。

# 実装方法について
WPFにてライブラリ等を用いずにメディア再生機能を実装する場合、主に以下の２パターンがありました。

### ①MediaElementのみ
WPFには「MediaElement」というメディア再生用のコントロールが標準で用意されていますが、このコントロールは再生ボタンやシークバー等が用意されていないため、自身で実装する必要があります。

MediaElementのみでシークバーを実現するためには「DispatcherTimer」オブジェクトを用いて特定のタイミングで「MediaElement.Position」プロパティを「Slider」コントロールの「Value」プロパティに同期する必要があります。
筆者も最初はこの方法で実現しようとしましたが、他の細かな制御が煩雑になってしまいました。。。泣

### ②MediaElement + MediaTimeline
①で実装したものを全て破棄し、もう一度作り直したのがこちらの方法です。
他のサイトでも様々な方が苦戦している様子でした。
一応MSから制御方法についてのドキュメントがありましたので掲載させていただきます。
[方法: ストーリーボードを使用して MediaElement を制御する](https://docs.microsoft.com/ja-jp/dotnet/desktop/wpf/graphics-multimedia/how-to-control-a-mediaelement-by-using-a-storyboard?view=netframeworkdesktop-4.8)

色々と調べた結果、MVVMモデルでの実装が難しかったため、ユーザーコントロールでの実装を行いました。
先にコードを掲載させていただきます。
※下記のコードはコピペで動作すると思われます。

### MediaPlayer.xaml
```xml
<UserControl x:Class="WPF_MediaPlayer.Controls.MediaPlayer"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WPF_MediaPlayer.Controls"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             PreviewKeyDown="UserControl_PreviewKeyDown">

    <UserControl.Resources>
        <Storyboard x:Key="TimelineStory" Name="TimelineStory" SlipBehavior="Slip">
            <MediaTimeline Name="Timeline"
                           Source="{Binding Source}"
                           Storyboard.TargetName="Media"
                           BeginTime="{Binding ElementName=Media, Path=Position}"
                           Duration="{Binding ElementName=Media, Path=NaturalDuration}"
                           CurrentTimeInvalidated="MediaTimeline_CurrentTimeInvalidated"/>
        </Storyboard>
    </UserControl.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <MediaElement Name="Media"
                      ScrubbingEnabled="True"
                      Loaded="Media_Loaded"
                      MediaOpened="Media_MediaOpened"
                      MediaEnded="Media_MediaEnded"
                      PreviewMouseLeftButtonDown="Media_PreviewMouseLeftButtonDown"/>

        <Slider Name="SeekSlider" Grid.Row="1" Margin="5"
                PreviewMouseLeftButtonDown="SeekSlider_PreviewMouseLeftButtonDown" 
                PreviewMouseLeftButtonUp="SeekSlider_PreviewMouseLeftButtonUp"
                ValueChanged="SeekSlider_ValueChanged" />

        <StackPanel Grid.Row="2" Orientation="Horizontal">
            <Button Name="PlayButton" Content="Play" Margin="5" Click="PlayButton_Click" Focusable="False"/>
            <Button Name="PauseButton" Content="Pause" Margin="5" Click="PauseButton_Click" Focusable="False"/>
            <Button Name="StopButton" Content="Stop" Margin="5" Click="StopButton_Click" Focusable="False"/>
            <Slider Name="VolumeSlider" Width="100" Maximum="1" Margin="5" Value="{Binding ElementName=Media, Path=Volume}"/>
        </StackPanel>

    </Grid>
</UserControl>
```

### MediaPlayer.xaml.cs
```cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Animation;

namespace WPF_MediaPlayer.Controls
{
    /// <summary>
    /// MediaPlayer.xaml の相互作用ロジック
    /// </summary>
    public partial class MediaPlayer : UserControl
    {
        
        #region Dependency Properties

        public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(Uri), typeof(MediaPlayer), new PropertyMetadata(null));
        public Uri Source
        {
            get { return (Uri)GetValue(SourceProperty); }
            set { SetValue(SourceProperty, value); }
        }

        #endregion

        #region Properties

        protected Storyboard TimelineStory
        {
            get { return (Storyboard)this.FindResource(nameof(TimelineStory)); }
        }

        protected bool IsPlaying
        {
            get { return this.Media.Clock != null && !this.IsPaused && !this.IsStopped; }
        }

        protected bool IsPaused
        {
            get { return this.Media.Clock != null && this.Media.Clock.IsPaused; }
        }

        protected bool IsStopped
        {
            get { return this.Media.Clock == null || this.Media.Clock.CurrentState.HasFlag(ClockState.Stopped); }
        }

        #endregion

        #region Constructors

        public MediaPlayer()
        {
            InitializeComponent();
        }

        #endregion

        #region Container Events

        private void UserControl_PreviewKeyDown(object sender, KeyEventArgs e)
        {
            switch (e.Key)
            {
                case Key.MediaPlayPause:
                    this.Play();
                    break;

                case Key.Space:
                    if (this.IsPlaying)
                    {
                        this.Pause();
                    }
                    else
                    {
                        this.Play();
                    }
                    break;

                case Key.Left:
                    this.Rewind(TimeSpan.FromSeconds(10));
                    break;

                case Key.Right:
                    this.Forward(TimeSpan.FromSeconds(10));
                    break;
            }
        }

        #endregion

        #region Control Events

        private void Media_Loaded(object sender, RoutedEventArgs e)
        {
            if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(this))
            {
                return;
            }
            this.Play();
            this.Stop();
        }

        private void Media_MediaOpened(object sender, EventArgs e)
        {
            this.SeekSlider.Maximum = this.Media.NaturalDuration.TimeSpan.TotalMilliseconds;
        }

        private void Media_MediaEnded(object sender, RoutedEventArgs e)
        {
            this.Stop();
        }

        private void Media_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            if (this.IsPlaying)
            {
                this.Pause();
            }
            if (this.IsStopped)
            {
                this.Play();
            }
            if (this.IsPaused)
            {
                this.Play(TimeSpan.FromMilliseconds(this.SeekSlider.Value));
            }
        }

        private void MediaTimeline_CurrentTimeInvalidated(object sender, EventArgs e)
        {
            this.SeekSlider.Value = this.Media.Position.TotalMilliseconds;
        }

        private void SeekSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            this.SeekSlider.IsMoveToPointEnabled = true;
            if (this.IsPlaying)
            {
                this.Pause();
            }
            if (this.IsStopped)
            {
                this.Play(TimeSpan.FromMilliseconds(this.SeekSlider.Value));
                this.Pause();
            }
        }

        private void SeekSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            if (this.IsPaused)
            {
                this.Play(TimeSpan.FromMilliseconds(this.SeekSlider.Value));
            }
            this.SeekSlider.IsMoveToPointEnabled = false;
        }

        private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
        {
            if (this.SeekSlider.IsMoveToPointEnabled)
            {
                this.TimelineStory.Seek(TimeSpan.FromMilliseconds(this.SeekSlider.Value));
            }
        }

        private void PlayButton_Click(object sender, RoutedEventArgs e)
        {
            this.Play();
        }

        private void PauseButton_Click(object sender, RoutedEventArgs e)
        {
            this.Pause();
        }

        private void StopButton_Click(object sender, RoutedEventArgs e)
        {
            this.Stop();
        }

        #endregion

        #region Methods

        public void Play()
        {
            if (this.IsStopped)
            {
                this.TimelineStory.Begin();
            }
            if (this.IsPaused)
            {
                this.TimelineStory.Resume();
            }
        }

        public void Play(TimeSpan position)
        {
            this.Seek(position);
            this.Play();
        }

        public void Seek(TimeSpan timeSpan)
        {
            var value = timeSpan;
            if (value.TotalMilliseconds < 0)
            {
                value = new TimeSpan();
            }
            this.TimelineStory.Seek(value);
        }

        public void Rewind(TimeSpan timeSpan)
        {
            this.Seek(TimeSpan.FromMilliseconds(this.SeekSlider.Value).Subtract(timeSpan));
        }

        public void Forward(TimeSpan timeSpan)
        {
            this.Seek(TimeSpan.FromMilliseconds(this.SeekSlider.Value).Add(timeSpan));
        }

        public void Pause()
        {
            this.TimelineStory.Pause();
        }

        public void Stop()
        {
            this.TimelineStory.Stop();
        }

        #endregion

    }
}
```

# 使い方
上記コードをコピペすれば動くと思われます。
使う際は「Source」プロパティに動画ファイルのUriをバインドしてください。

# 注意点

①Media関連の制御は全て「Storyboard」「MediaTimeline」を用いて制御しています。
②再生状況のステータスは、「MediaElement」の「Clock」プロパティから取得できます。
③MediaElementの「Loaded」イベントにて「Play」「Pause」メソッドを呼び出しているのは、サムネイルの読み込みを実施するためです。
④Seek用のSliderの「PreviewMouseLeftButtonXXXX」イベントにて「IsMoveToPointEnabled」の制御を行っています。
※SeekSliderの「ValueChanged」イベント内で「IsMoveToPointEnabled」の値を元に動画をSeekする処理を行っているので、プロパティの値を変更する場合は注意してください。

# まとめ
思い当たる機能を実装してみましたが、WPF準拠の機能のみでMedia関連の実装するのは大変でした。
Nuget等のライブラリを使うことも検討していたのですが、調べたら使わずとも実装できそうだったのでやってみました。
現在はローカルファイルにのみ対応していますが、本来の要件的にストリーミング再生に対応できないと厳しそうです。。。泣

また、時間があるときに再挑戦したいと思います。
何か不備や質問がありましたらご連絡ください。