[WinRT] ストアアプリで動画のサムネールを取得する

怒涛の GridView シリーズの続きです。Kindle Launcher ネタはひと区切りついたので MP4 Launcher で使っているテクニックの紹介です。とある理由で、MP4 ファイルがたくさんあると仮定して、Surface RT やら PC でそれを観賞しようとすると、真っ先に動くのはストアアプリの「ビデオ」アプリなんですが、このアプリ、いちいちフォルダを開いて動画ファイルを探しに行かないといけないし、そもそも前の状態を覚えておいてくれないので、シリーズものを連続で観賞するには非常に不便です。いきおい、従来の Windows Media Player を動かすという手もあるのですが、Surface のようなタブレットの場合、音量の調節とか「標準ビデオ」のほうがやりやすいんですよね。

以前、シリーズ毎のストアアプリを作ってみたものの、シリーズ毎に自作しなくてはいけなくて途中で面倒になってしまいました。で、Kindle Launcher と同じようにスタート画面にピン留めすれば良いのでは?と思って勢いで作ったのが MP4 Launcher です。
Kindle Launcher の場合は、漫画の表紙をタイルに表示しています。表紙そのものが png あるいは jpeg でダウンロードできるので比較的簡単です。ビデオファイルの場合はどうするのか、と悩むところなのですが、実は WinRT には動画ファイルのサムネールを取得できる機能があります。

ファイルピッカーを使うと、mp4 のような動画ファイルはサムネールが表示されます。動画ファイルだけでなく
ピクチャファイルなどのサムネールも取れたりするのですが、ここでは動画を対象にします。この機能をそのまま、自作アプリに持ってこれるといいですよね。

自作アプリ内の GridView に表示させるとこんな感じになります。それぞれのセルにサムネールを表示させています。サムネール自体は、自動で作成されるらしく動画の位置を指定することはできません(と思います)。なので、時には同じ画像ばっかりが並ぶことがあるのですが、大抵の場合は違う画像が並びます。

フォルダを開くピッカーを使う

まずは、動画の入っているフォルダを指定します。ファイル指定でもいいのですが、大抵はシリーズものがひとつのフォルダにまとめてある(と思われる)ので、ユーザーにフォルダを指定して貰います。

private async void OnButtonItemSearch(object sender, RoutedEventArgs e)
{
    var picker = new FolderPicker();
    picker.CommitButtonText = "フォルダを指定する";
    picker.ViewMode = PickerViewMode.Thumbnail;
    picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
    picker.FileTypeFilter.Add(".mp4");

    var folder = await picker.PickSingleFolderAsync();

PickerViewMode には、リスト形式(List)とサムネール形式(Thumbnail)があります。ここでは Thumbnail を指定しておきます。このあたりはファイルを指定するときと同じです。

フォルダ内の動画のサムネールを取得する

ピッカーで取れたフォルダを自前のリストに保存します。リストは ViewModel スタイルにして、GridView にバインドできるようにしておきます。

public class VideoList : ObservableCollection<Video> { }
public class Video : BindableBase
{
    /// <summary>
    /// タイトル
    /// </summary>
    private string _Title;
    public string Title
    {
        get { return _Title; }
        set { this.SetProperty(ref this._Title, value); }
    }
    /// <summary>
    /// ファイルパス
    /// </summary>
    private string _Path;
    public string Path
    {
        get { return _Path; }
        set { this.SetProperty(ref this._Path, value); }
    }
    /// <summary>
    /// サムネール自体
    /// </summary>
    private Windows.Storage.FileProperties.StorageItemThumbnail _Thum;
    public Windows.Storage.FileProperties.StorageItemThumbnail Thum
    {
        get { return _Thum; }
        set { this.SetProperty(ref this._Thum, value); }
    }
    /// <summary>
    /// サムネールのBitmap
    /// </summary>
    private ImageSource _IconImage;
    public ImageSource IconImage
    {
        get { return _IconImage; }
        set { this.SetProperty(ref this._IconImage, value); }
    }
    /// <summary>
    /// サムネールの保存パス名
    /// </summary>
    private string _IconUrl;
    public string IconUrl
    {
        get { return _IconUrl; }
        set { this.SetProperty(ref this._IconUrl, value); }
    }
}

ちょっと、ややこしいですが、サムネール自体(Thum)とサムネールのビットマップ(IconImage)を別に用意しておきます。ビットマップのほうは Image コントロールにバインドするものです。サムネール自体を保存しているのは、スタート画面にピン留めするためにアプリケーションデータにファイル保存をするためです。

XAML 自体は、こんな風になります。Image コントロールに Source=”{Binding IconImage}” でバインドですね。これと Video クラスの IconImage プロパティが結びつきます。

<GridView
    Grid.Column="1" Grid.Row="1"
    x:Name="itemGridView"
    TabIndex="1"
    Padding="10"
    SelectionMode="{Binding SelectMode}"
    ItemsSource="{Binding Items}"
    IsSwipeEnabled="false"
    Tapped="itemGridView_Tapped">
    <GridView.ItemTemplate>
        <DataTemplate>
            <Grid HorizontalAlignment="Left" Width="250" Height="250">
                <Border Background="{StaticResource ListViewItemPlaceholderBackgroundThemeBrush}">
                    <Image Source="{Binding IconImage}" Stretch="UniformToFill" />
                </Border>
                <StackPanel VerticalAlignment="Bottom" Orientation="Vertical"
                    Background="{StaticResource ListViewItemOverlayBackgroundThemeBrush}">
                    <TextBlock Text="{Binding Title}"
                        Foreground="{StaticResource ListViewItemOverlayForegroundThemeBrush}"
                        Height="30" Margin="3"
                        TextWrapping="NoWrap"/>
                </StackPanel>
            </Grid>
        </DataTemplate>
    </GridView.ItemTemplate>
</GridView>

フォルダのピッカーで拾えるのは、StorageFolder オブジェクトなので、この中のふぁいるからサムネールを取ってきます。

async void OpenFolder(StorageFolder folder)
{
    if (folder == null) return;
    vm.FolderPath = folder.Path;
    vm.FolderName = folder.Name;
    // 拡張子 *.mp4 のファイルを探す
    var lst = await folder.GetFilesAsync();
    var items = lst.Where(x =>
    {
        switch (System.IO.Path.GetExtension(x.Name))
        {
            case ".mp4":
                return true;
            default:
                return false;
        }
    })
        .Select(async x =>
        {
            var thum = await x.GetThumbnailAsync(Windows.Storage.FileProperties.ThumbnailMode.VideosView);
            var bmp = new BitmapImage();
            bmp.SetSource(thum);
            return new Video()
            {
                Title = x.DisplayName,
                Path = x.Path,
                Thum = thum,
                IconUrl = x.Name + ".png",
                IconImage = bmp,
            };
        }
    );
    vm.Items.Clear();
    foreach (var it in items)
    {
        vm.Items.Add(await it);
    }
}
  1. GetFilesAsync クラスでフォルダ内のファイルを探索
  2. LINQ の Where メソッドで拡張子 .mp4 を拾い出す(switch になっているのは、.mpeg なども調べられるように修正した名残です)。
  3. Select メソッドで Video オブジェクトを作成する

サムネール自体は、GetThumbnailAsync メソッドで作成できます。これを Image コントロールに渡せる BitmapImage に直すためには、そのまま SetSource メソッドを呼び出せば ok です。BitmapImage オブジェクトは GC で不要になったら解放されます。サムネールを自前の GridView に表示している間、保持しておけばよいわけです。具体的には、ViewModel にあたる VideoList オブジェクトがキープされる間、保持されています。

これで先ほどの自前のサムネール表示ができます。ここではサムネール自体を加工せずに表示させていますが、ちょっと手間をかければ加工することも可能です。BitmapImage オブジェクトはそのままでは加工できないので、WritableBitmap に変換するか、DirectX を使います。C# から直接 DirectX を使うことはできないので Win2D(NuGet で取得できます)を使うとよいでしょう。Win2D での加工は今度やってみましょう。

サムネールをアプリケーションデータに保存する

Kindle Launcher のように、動画のサムネール画像を使ってスタート画面にピン留めできるようにしておきましょう。タイルに表示させる画像はリソースかアプリケーションデータ内と決まっているので、アプリデータにユニークな名前になるように保存します。動画ファイルのファイル名をそのまま使ってもよいのですが、セカンダリタイルの TileID として使いたいので、名前をユニークになるように変換します。TileID には「(」などの特殊な記号が使えないので、この方法を使っています。

private string toMD5(string s)
{
    //文字列をbyte型配列に変換する
    byte[] data = System.Text.Encoding.UTF8.GetBytes(s);

    //MD5CryptoServiceProviderオブジェクトを作成
    var md5 = HashAlgorithmProvider.OpenAlgorithm(HashAlgorithmNames.Md5);
    BinaryStringEncoding encoding = BinaryStringEncoding.Utf8;
    var buff = CryptographicBuffer.ConvertStringToBinary(s, encoding);
    var hash = md5.CreateHash();
    hash.Append(buff);
    var dest = hash.GetValueAndReset();
    string signature = CryptographicBuffer.EncodeToHexString(dest);
    return signature;
}

private async System.Threading.Tasks.Task<bool> CreateSecondaryTileAsync(Video item)
{
    string tileId = "MP4-" + toMD5(item.Title);
    var tile = new Windows.UI.StartScreen.SecondaryTile()
    {
        TileId = tileId,
        DisplayName = item.Title,
        Arguments = toMD5(item.Title),
        RoamingEnabled = true,
    };
    tile.VisualElements.ForegroundText = Windows.UI.StartScreen.ForegroundText.Light;
    tile.VisualElements.ShowNameOnSquare150x150Logo = true;

    // アプリローカルに保存
    var stt = item.Thum.GetInputStreamAt(0);
    byte[] data = new byte[item.Thum.Size];
    var st = stt.AsStreamForRead();
    st.Read(data, 0, data.Length);
    var folder = Windows.Storage.ApplicationData.Current.LocalFolder;
    var path = toMD5(item.Title) + ".jpg";
    try
    {
        var file = await folder.CreateFileAsync(path);
        using (var sw = await file.OpenStreamForWriteAsync())
        {
            sw.Write(data, 0, data.Length);
            sw.Flush();
        }
    }
    catch
    {
        // 同名のファイルがある場合は、そのまま使う
    }
    // テキストファイルにmp4のパスを保存
    var path2 = toMD5(item.Title) + ".txt";
    try
    {
        var file = await folder.CreateFileAsync(path2);
        using (var sw = await file.OpenStreamForWriteAsync())
        {
            var data2 = System.Text.Encoding.UTF8.GetBytes(item.Path);
            sw.Write(data2, 0, data2.Length);
            sw.Flush();
        }
    }
    catch
    {
        // 同名のファイルがある場合は、そのまま使う

    }
    tile.VisualElements.Square150x150Logo = new Uri("ms-appdata:///local/" + path);
    tile.VisualElements.Square30x30Logo = new Uri("ms-appdata:///local/" + path);

    return await tile.RequestCreateForSelectionAsync(GetElementRect(this.btnMakeTile));
}

public static Rect GetElementRect(FrameworkElement element)
{
    GeneralTransform buttonTransform = element.TransformToVisual(null);
    Point point = buttonTransform.TransformPoint(new Point());
    return new Rect(point, new Size(element.ActualWidth, element.ActualHeight));
}

サムネールを取得したときの Thum プロパティの値を使います。StorageFile クラスの GetInputStreamAt メソッドを使って先頭位置からのストリームを取得します。byte[] を使っていますが、System.IO.MemoryStream を使ってもよいでしょう。このあたりは定番の処理になります。

たまたまサムネールの位置が同じ場合には同じ画像が並んでしまうのと、タイトルで表示するときに文字が読み辛くなるという難点はありますが、ひとまずスタート画面に画像付きのタイルができます。自前のリストにも画像付きのリストがでいるので、結構見栄えがよくなるのではないでしょうか。

サムネール保存時に遅延が発生する

ファイルピッカーもそうなのですが、サムネールを表示するときに遅延が発生します。await/async を使ってバインドを使っているせいなのですが、ちょっと面白い/困った現象が出ます。ViewModel でバインドをしているので、サムネールの画像が取得できたときに画面に表示されます。この操作自体がパラレルで行われるためか、次のようにたくさん動画ファイルのあるフォルダを指定したときに問題が出ます。

  1. 動画ファイルがたくさんあるフォルダを指定する。
  2. サムネールが順々に表示される。
  3. 表示の途中で、別のフォルダを選択する。
  4. 別のフォルダを指定したが、前のフォルダのサムネールがいくつか表示される。

4 のように、まだサムネールを取得しきっていないセルが GridView に表示されてしまいます。これはサムネールの遅延処理のための対策が、先にコードに不十分と思われるので、何か対処が必要ですよね。まあ、このあたりが ViewModel と遅延処理(あるいは重たい処理)の弊害ってところなんです。この話はまた別途。

 

カテゴリー: C#, WinRT パーマリンク