🎄

Tauriでマルチプラットフォームアプリを作ろう!

2023/12/14に公開

はじめに

この記事はパン屋さん。主催の全国高専_非公式 Advent Calendar 2023に参加しています!
他の記事はこちらから!
https://t.co/38eIClHk7E

概要

パッと何かを思い立ってアプリを作った時にアプリがマルチプラットフォームに対応していると何かと嬉しいですよね。
今回はTauriというフレームワークを使ったマルチプラットフォームアプリの作り方について、
私が以前作ったアプリを交えて簡単に解説していきます!

自己紹介

福井高専 5年
Unityを弄るのが好きで高専プロコンとか出ました
最近は燈株式会社で長期インターンをしていてWeb開発をメインでやっています

言い訳

作ったもの

https://github.com/void2610/Simple-YouTube-Player
こちらが今回制作したアプリです。

動作している動画
https://youtu.be/qNA2knqxbIE

YouTubeの動画/プレイリストのurlを入力すると、その動画を広告なしで再生することができます。
更にトラック管理機能、履歴機能、テーマ(色)変更機能などをつけてみました

使用場面としては主にゲーム中に裏で動画や音楽を流す場面を想定しています。
広告ブロックのために Chromeなどの激重ブラウザをゲーム中に開くことから解放されます!

また、本アプリはReactの練習がてら作ったものなのとそんなに複雑な処理は必要なかったため、フロントの処理(js)のみで完結しています。

※今回はYouTubeのAPIキーがハードコードされていますが、OpenAIのAPIキー等、利用にお金がかかるAPIキーをハードコードしてGitHub等にアップするのはやめましょう。

使用した技術

Tauri

https://tauri.app/
(以下Bing AIの解説)

Tauriは、Rustで書かれた軽量なGUIフレームワークで、Windows、macOS、Linux向けのデスクトップアプリを開発できます12。Tauriは、メインプロセスをRustで記述しますが、UI(User Interface)にはWeb技術を利用します1。ReactやVue.jsのようなJavaScriptフレームワークがそのまま使えるので、インタラクティブで見栄えの良いUIを簡単に構築できます1。
Tauriの特徴の一つは、その軽量さです。同種のフレームワークにElectronがありますが、後発であるTauriにはインストーラのサイズを小さくできるなどの強みがあります1。TauriアプリにはChromiumやNode.jsが含まれないため、インストーラのサイズが小さくなります1。また、メインプロセスをRustで記述するため、メモリ使用量の面でも有利です1。
Tauriのロゴは、おうし座の二重星であるシータタウリ(θ Tauri)をモチーフにしており、Webとネイティブアプリの相互作用を意味しています1。

マルチプラットフォームのpcアプリを作れるrustフレームワークです。
勝手に主観でまとめると

  • フロントにJSフレームワークを使えるので宣言的にUIが書ける
  • バックをRustで書けるので高速?(Rustわからん)
  • マルチプラットフォームでMac,Win,Linuxに対応

このような利点があると思います。
特に様々な便利技術があるJSフレームワークを使えるのはでかいと思います。

YouTube Data API

https://developers.google.com/youtube/v3/docs?hl=ja
(以下Bing AIの解説)

YouTube Data APIは、YouTubeが提供するWebサービスをプログラムから呼び出して利用できるように公開されたAPIです1。このAPIを利用することで、YouTubeのデータをアプリケーションで利用することが可能になります2。
具体的な利用手順は以下の通りです34:
Google Cloud Consoleにアクセス3:Googleアカウントにログインした状態で、Google Cloud Consoleにアクセスします3。
プロジェクトの作成34:新しいプロジェクトを作成します34。
YouTube Data API V3の有効化34:作成したプロジェクト内でYouTube Data API V3を有効化します34。
APIキーの生成と設定34:YouTube Data API V3を使うには認証が必要になりますので、その認証にするAPI Keyを発行します34。
これらの手順を実行することで、YouTube Data API V3を使用するためのプロジェクトの設定とAPIキーの生成が完了します4。
YouTube Data APIは、自分の動画の分析よりも、YouTube全体や他のYouTubeチャンネルの分析に向いています5。再生回数の降順で動画を取得することで、特定のジャンルで再生回数の多い動画のタイトルやサムネイル画像を分析することができます5。

YouTubeのいろんなデータをAPIで取得できます。
今回はYouTubeの動画ページのurlから動画データのurlを取得する機能を使います。
このAPIを使うことで動画ページのurlを入力として、動画データだけを取得できるため、あとはそれを再生できるようにするだけで広告なしのYouTubeプレイヤーの出来上がりです。簡単ですね!!

React

(以下Bing AIの解説)

もちろんです。Reactは、Meta社(旧Facebook社)が開発したJavaScriptライブラリで、ユーザインタフェースを構築するために使われます¹²。以下に、Reactの主な特徴と利点をまとめてみました。
Reactの特徴

  1. 宣言的(View): React.jsでは、アプリの内部状態に応じて、表示内容の指示を宣言することができます¹。これにより、UI部分のロジックを読み解くことなく、直感的に画面に表示されるUIのイメージを持てます¹。
  2. コンポーネントベース: React.jsでは、コンポーネントを作成し、必要に応じて組み合わせることによりインターフェースを構築していきます¹。コンポーネントベースであれば、後から変更したいときでも対象のコンポーネントのみ更新することができます¹。
  3. 一度学習すれば、どこでも使える: React.jsでは新しい機能を追加する場合でも、既存のソースコードを書き換える必要がありません¹。また、React.jsを学ぶことでWebサイトやアプリ開発だけでなく、スマホアプリ開発に使われる「React Native」というフレームワークも使えるようになります¹。
    Reactの利点
  4. Webページの表示速度が速くなる: React.jsには仮想DOM (Virtual Document Object Model)というレンダリング機能が採用されています¹。React.jsはページを更新するときに、ページ全体をレンダリングする必要がなく、必要な部分のみを更新する仕組みとなっています¹。
  5. 大規模な開発が可能になる: ページ量の多い大規模な開発では、あるページの一部分を変更した場合に他のページにも影響を及ぼしてしまい、変更を加える度に全体的なテストを行う必要が出てきます¹。一方React.jsはコンポーネントごとに分解されているため、効率的に修正を加えることができます¹。
  6. 導入コストがかからない: React.jsは、Meta社が開発し、オープンソースとして公開されているJavaScriptライブラリです¹。そのためコストをかけることなく、手軽に導入できる点がメリットの一つです¹。
  7. 最先端のユーザーインターフェースを作れる: React.jsは、最先端のユーザーインターフェースを構築できるというメリットがあります¹。React.jsは世界中で多くのユーザーが活用しており、中でもライブラリにある各コンポーネントは洗練されたデザインとなっています¹。

最高の解説です。
BingAIくんが全部言ってくれましたが、Reactは再利用可能なコンポーネントの組み合わせで高速なWebUIを宣言的に構築できます。
また、React向けの様々なライブラリが存在しているため、大体のことはライブラリで簡単に解決できます。

環境構築

まず以下のコマンドでTauriアプリの雛形を作成します。

yarn create tauri-app

すると対話型インターフェースでプロジェクトの基本設定が始まります。
下のように答えると今回と同じ設定になります。

✔ Project name · Simple-YouTube-Player
✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, bun)
✔ Choose your package manager · yarn
✔ Choose your UI template · React - (https://reactjs.org/)
✔ Choose your UI flavor · JavaScript

次に以下のコマンドで依存関係をインストールします。

yarn install

これで環境構築は完了です!

yarn dev

でアプリを起動し、表示されたポート(デフォルトはhttp://localhost:1420)にアクセスすると、下のような画面が表示されるはずです。

実際の環境で試したい方は

yarn tauri dev

でWebではなくアプリケーションとして起動できます。
エラーが出た場合は、

rustup update

でRustをアップデートしてから試してみてください。

ディレクトリの説明

(以下Bing AIの解説)

yarn create tauri-appで生成された各ディレクトリの役割は以下の通りです:
README.md: プロジェクトの説明や使用方法などを記述するためのマークダウンファイルです。
index.html: アプリケーションのエントリーポイントとなるHTMLファイルです。
node_modules: プロジェクトで使用するnpmパッケージがインストールされるディレクトリです。
package.json: プロジェクトのメタデータや依存関係、スクリプトなどを定義するファイルです。
public: 静的ファイル(画像、スタイルシート、JavaScriptファイルなど)を格納するディレクトリです。
src: アプリケーションのソースコードを格納するディレクトリです。
src-tauri: Tauriの設定ファイルやRustのソースコードを格納するディレクトリです。
vite.config.js: Viteの設定ファイルです。Viteは、モダンなフロントエンドフレームワーク向けのビルドツールです。
yarn.lock: プロジェクトの依存関係のバージョンを正確に追跡するためのファイルです。
これらのファイルとディレクトリは、Tauriアプリケーションの構築と実行に必要なものです12。それぞれの役割を理解することで、効率的に開発を進めることができます。12。

今回主に弄っていくのはsrcディレクトリになります。
src内にはフロントの処理を記述します。

特に重要なのがApp.jsxファイルです。
App.jsxファイルはアプリ起動時に表示される、エントリーポイントとなるファイルになります。

コード解説

ここからは実際にアプリ内のコードを交えながら、実装を紹介していきます。
なんとなく書き方の雰囲気を掴んでいただけたらと思います。

エントリポイント

少し長いですが、以下が本アプリのApp.jsxです。

App.jsx
import { useRef, useState, useEffect } from "react";
import { createTheme } from '@mui/material/styles';
import { ThemeProvider } from '@mui/material/styles';
import { invoke } from "@tauri-apps/api/tauri";
import DisplayTrack from "./components/DisplayTrack";
import Controls from "./components/Controls";
import TopBar from "./components/TopBar";
import SearchBar from "./components/SearchBar";
import PlayList from "./components/PlayList";
import History from "./components/History";
import Settings from "./components/Settings";

const App = () => {
  // テーマの設定
  const firstTheme = createTheme({
    palette: {
      mode: 'dark',
      primary: {
        main: '#535bf2',
      },
    },
  })

  const [theme, setTheme] = useState(firstTheme);

  // ローカルストレージから履歴データを取得
  const savedHistories = localStorage.getItem('histories');
  const [histories, setHistories] = useState(savedHistories ? JSON.parse(savedHistories) : []);

  const [drawerOpened, setDrawerOpened] = useState(false);
  const [isDisplayTrack, setIsDisplayTrack] = useState(true);
  const savedTheme = localStorage.getItem('theme');
  const [tracks, setTracks] = useState([]);
  const [trackIndex, setTrackIndex] = useState(0);
  const [currentTrack, setCurrentTrack] = useState(tracks[trackIndex]);
  const playerRef = useRef();

  useEffect(() => {

    if (savedTheme) {
      const newTheme = createTheme(JSON.parse(savedTheme));
      setTheme(newTheme);
    }
  }, []);

  // 履歴データが更新されたときにローカルストレージに保存
  useEffect(() => {
    localStorage.setItem('histories', JSON.stringify(histories));
  }, [histories]);

  useEffect(() => {
    localStorage.setItem('theme', JSON.stringify(theme));
  }, [theme]);

  const handleNext = () => {
    if (trackIndex >= tracks.length - 1) {
      setTrackIndex(0);
      setCurrentTrack(tracks[0]);
    } else {
      setTrackIndex((prev) => prev + 1);
      setCurrentTrack(tracks[trackIndex + 1]);
    }
  };

  return (
    <>
      <ThemeProvider theme={theme}>
        <TopBar {...{ isDisplayTrack, setIsDisplayTrack, drawerOpened, setDrawerOpened, theme }} />
        {isDisplayTrack && (
          <>
            <SearchBar
              {...{
                tracks,
                setTrackIndex,
                setTracks,
                setCurrentTrack,
                histories,
                setHistories
              }}
            />
            <div className="audio-player">
              <div className="inner">
                <PlayList
                  {...{
                    currentTrack,
                    tracks,
                    trackIndex,
                    setTrackIndex,
                    setCurrentTrack,
                    theme
                  }}
                />
                <div>
                  <DisplayTrack
                    {...{
                      currentTrack,
                      tracks,
                      trackIndex,
                      setTrackIndex,
                      setCurrentTrack,
                    }}
                  />
                  {currentTrack && (
                    <Controls
                      {...{
                        playerRef,
                        tracks,
                        trackIndex,
                        setTrackIndex,
                        setCurrentTrack,
                        handleNext,
                      }}
                    />)}
                </div>
              </div>
            </div>
          </>
        )}
        {!isDisplayTrack && (
          <>
            <History
              {...{
                currentTrack,
                tracks,
                setTracks,
                trackIndex,
                setTrackIndex,
                setCurrentTrack,
                histories,
                setHistories
              }}
            />
          </>
        )}
        <Settings  {...{ drawerOpened, setDrawerOpened, theme, setTheme }} />
      </ThemeProvider>
    </>
  );
};
export default App;

Reactをモリモリ書いてる今の自分から見るとかなりお粗末な設計ですね...

今回実装したのは動画再生画面と履歴閲覧画面の2画面のみなので、ページ遷移は実装せず、App.jsx内に全てのコンポーネントを配置しました。
isDisplayTrackというStateでどちらの画面を表示するか分けています。

全体の詳しい実装はGitHubリポジトリを見てもらうとして、以下で根幹の部分くらいは詳しく解説しようと思います。

API呼び出し

以下はユーザの入力を受けとりAPIを呼び出しているSearchBar.jsxの一部です。

SearchBar.jsx
...
const [url, setUrl] = useState('');
...
<TextField fullWidth id='search-bar-input' label="Search" variant="standard"
          color='primary'
          placeholder="Input YouTube playlist URL"
          onChange={(e) => setUrl(e.target.value)}
          onKeyDown={handleKeyDown}
          InputProps={{
            style: {
              color: '#c2c2c2',
              boxShadow: 'none'
            }
          }}
          InputLabelProps={{
            style: {
              color: '#c2c2c2'
            }
          }}
        />
...

この部分で、TextFieldに入力された文字列をurlというStateに保存しています。

SearchBar.jsx
...
const handleKeyDown = async (event) => {
    if (event.key === 'Enter') {
      startSearch();
    }
  }
  
...
 
<TextField fullWidth id='search-bar-input' label="Search" variant="standard"
          color='primary'
          placeholder="Input YouTube playlist URL"
          onChange={(e) => setUrl(e.target.value)}
          onKeyDown={handleKeyDown}
          InputProps={{
            style: {
              color: '#c2c2c2',
              boxShadow: 'none'
            }
          }}
          InputLabelProps={{
            style: {
              color: '#c2c2c2'
            }
          }}
        />
        <Tooltip title="Search">
          <IconButton type="button" sx={{ p: '10px' }} aria-label="search"
            onClick={() => { startSearch() }}
            style={{ color: '2f2f2f', boxShadow: 'none', marginLeft: '12px' }}>
            <SearchIcon style={{ color: '#c2c2c2' }} />
          </IconButton>
        </Tooltip>
...

検索ボタンかEnterキーが押された時にstartSearch関数を呼びます。

startSearch
...
async function startSearch() {
    if (url.includes('list=')) {
      const newTracks = await getTracksFromPlaylistUrl(url);
      setTracks(tracks => [...tracks, ...newTracks]);

      const playlist = await createHistoryFromPlaylistUrl(url);
      for (let i = 0; i < histories.length; i++) {
        if (histories[i].src === playlist.src) {
          histories.splice(i, 1);
          break;
        }
      }
      setHistories(histories => [...histories, playlist]);
    } else {
      const track = await getTrackFromUrl(url);
      setTracks(tracks => [...tracks, track]);

      const history = await createHistoryFromUrl(url);
      for (let i = 0; i < histories.length; i++) {
        if (histories[i].src === history.src) {
          histories.splice(i, 1);
          break;
        }
      }
      setHistories(histories => [...histories, history]);
    }
    document.getElementById('search-bar-input').value = '';
  }
...

startSearch関数ではurlにlist=という文字列が含まれているかで動画/プレイリストのurlを判別し、APIからのレスポンスを受け取ってStateに格納しています。
次に実際にAPIを呼び出しているgetTrackFromUrl関数を見てみましょう。

getTrackFromUrl
async function getTrackFromUrl(url) {
    var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
    var match = url.match(regExp);
    const videoId = (match && match[7].length == 11) ? match[7] : false;

    const response = await fetch(`https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${videoId}&key=${API_KEY}`);
    const data = await response.json();

    if (!data.items || data.items.length === 0) {
      throw new Error('No video found');
    }

    const videoData = data.items[0].snippet;

    return {
      title: videoData.title,
      src: url,
      author: videoData.channelTitle,
      thumbnail: videoData.thumbnails.default.url
    };
  }

正規表現を使ってYoutubeの動画ページのurlから動画IDを切り出し、動画IDとAPIキーを使ってAPIを呼び出します。
動画が見つかった場合はタイトル、動画データのurl、チャンネル名、サムネイルをオブジェクトとして返します。

これらを使い、SearchBarコンポーネントではurlから動画を検索し、動画情報をトラックリストと履歴に追加しています。

動画再生

動画の再生や操作はDisplayTrackControlsコンポーネントで行っています。

DisplayTrack
import ReactPlayer from 'react-player';

const DisplayTrack = ({
  currentTrack,
  tracks,
  trackIndex,
  setTrackIndex,
  setCurrentTrack
}) => {

  const playNext = () => {
    if (trackIndex === tracks.length - 1) {
      setTrackIndex(0);
      setCurrentTrack(tracks[0]);
    } else {
      setTrackIndex((prev) => prev + 1);
      setCurrentTrack(tracks[trackIndex + 1]);
    }
  }

  return (
    <div>
      {currentTrack && (
        <ReactPlayer url={currentTrack.src} controls={true} playing={true} onEnded={playNext} />
      )}
    </div>
  );
};

export default DisplayTrack;

動画プレイヤーはreact-playerというライブラリで簡単に実装できました。
onEndedプロパティに現在動画のStateを次の動画に進める関数を設定することで、自動再生も実装しました。

Controls.jsx
import { IconButton } from '@mui/material'
import { useState, useEffect, useRef, useCallback } from 'react';

// icons
import FastForwardRounded from '@mui/icons-material/FastForwardRounded';
import FastRewindRounded from '@mui/icons-material/FastRewindRounded';

const Controls = ({
  playerRef,
  tracks,
  trackIndex,
  setTrackIndex,
  setCurrentTrack,
  handleNext,
}) => {

  const [isPlaying, setIsPlaying] = useState(false);

  useEffect(() => {
    if (playerRef && playerRef.current) {
      if (isPlaying) {
        playerRef.current.getInternalPlayer().playVideo();
      } else {
        playerRef.current.getInternalPlayer().pauseVideo();
      }
    }
  }, [isPlaying, playerRef]);


  const handlePrevious = () => {
    if (trackIndex === 0) {
      let lastTrackIndex = tracks.length - 1;
      setTrackIndex(lastTrackIndex);
      setCurrentTrack(tracks[lastTrackIndex]);
    } else {
      setTrackIndex((prev) => prev - 1);
      setCurrentTrack(tracks[trackIndex - 1]);
    }
    setIsPlaying(true);
  };

  return (
    <div className="controls-wrapper">
      <div className="controls">
        <IconButton aria-label="previous song" onClick={handlePrevious} style={{ color: 'white', boxShadow: 'none' }}>
          <FastRewindRounded fontSize="large" />
        </IconButton>
        <IconButton aria-label="next song" onClick={handleNext} style={{ color: 'white', boxShadow: 'none' }}>
          <FastForwardRounded fontSize="large" />
        </IconButton>
      </div>
    </div>
  );
};

export default Controls;

このコンポーネントでボタンで動画を前後させる機能を実装しています。
Stateの変更処理とボタンを紐づけただけです。

履歴の保存

TauriはwryというWebView のラッパーによってMacの場合はWebKit、Windowsの場合はWebView2を使ってフロントを動作させています。
そのため、Web開発で使うことのできる機能はほぼ全て使うことができます。

本アプリではアプリを閉じても動画の再生履歴が保存されます。
このような機能はUnityでは毎回かなり頭を捻らせて実装していますがTauriではWebブラウザのローカルストレージ機能を使うことで非常に簡単に実装できました。

App.jsx
...
// ローカルストレージから履歴データを取得
  const savedHistories = localStorage.getItem("histories");
  const [histories, setHistories] = useState(savedHistories ? JSON.parse(savedHistories) : []);
  const savedTheme = localStorage.getItem("theme");
  
  // 履歴データが更新されたときにローカルストレージに保存
  useEffect(() => {
    localStorage.setItem("histories", JSON.stringify(histories));
  }, [histories]);

  useEffect(() => {
    localStorage.setItem("theme", JSON.stringify(theme));
  }, [theme]);
...

このように、localStorage.setItem("データ名", jsonデータ)とすることで、簡単にブラウザ(WebView)のローカルストレージにデータを保存することができます。
また、localStorage.getItem("データ名")とすることで簡単に保存したデータを読み出せます。

この機能で本アプリでは動画の再生履歴とUIのテーマ色を管理しています。

History.jsx
...
const clearHistories = () => {
    setHistories([]);
    localStorage.removeItem("histories");
  }
...

また、localStorage.removeItem("データ名")とすることで、データの削除も簡単にできます。

ビルド

最後にマルチプラットフォーム向けのビルド方法を解説します。
私は今回GitHub Actionsを用いてmainブランチにpushされたデータをマルチプラットフォーム向けに自動ビルドするようにしました。
以下がworkflowファイルです。

.github/workflows/build.yaml
name: "publish"
on:
 push:
  branches:
   - main

jobs:
 publish-tauri:
  permissions:
   contents: write
  strategy:
   fail-fast: false
   matrix:
    platform: [macos-latest, ubuntu-20.04, windows-latest]

  runs-on: ${{ matrix.platform }}
  steps:
   - name: Enable longpaths
     if: runner.os == 'Windows'
     run: git config --system core.longpaths true
   - uses: actions/checkout@v3
   - name: setup node
     uses: actions/setup-node@v3
     with:
      node-version: 16
   - name: install Rust stable
     uses: dtolnay/rust-toolchain@stable
   - name: install dependencies (ubuntu only)
     if: matrix.platform == 'ubuntu-20.04'
     run: |
      sudo apt-get update
      sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
   - name: install frontend dependencies
     run: yarn install # change this to npm or pnpm depending on which one you use
   - uses: tauri-apps/tauri-action@v0
     env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
     with:
      tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
      releaseName: "App v__VERSION__"
      releaseBody: "See the assets to download this version and install."
      releaseDraft: true
      prerelease: false

私もあんまり内容は理解していませんが、とりあえずこれを配置してpushするだけでGitHubが勝手にビルドしてくれます!!
(Releaseに設定するのを忘れずにやりましょう)

おわりに

ここまで読んでいただいてありがとうございました!

今回はTauriを使って簡単にマルチプラットフォームなアプリを作ってみました!
Reactを使ったWeb開発経験がある方からは常識レベルの実装ですが、授業ではvanilla JSしか触っていなかったのでここまで完成させるのになかなか苦労しました...

ただReactは慣れてしまえば絶対にvanilla JSに戻りたくないというレベルで書きやすいのでまだReactに触れたことのない方は是非触ってみてください!
(Rustはまたいつか勉強したい...)




この記事はパン屋さん。主催の非公式_全国高専 Advent Calendarに参加しています!
他の記事はこちらから!
https://t.co/38eIClHk7E

Discussion