親友の結婚式二次会のためにAngularJSでリアルタイムスライドショーを開発した話

本稿はちょうど一年前に**[自身のブログ記事](https://kt-kiyoshi.com/wp/2017/02/11/realtime-slide-show-by-angular/)**として投稿したもののを、技術的な部分中心に抜粋したものになります。

また、利用しているAngularJSのversionは1系となるため、Qiita界隈の方々におかれましては、あくまで"読み物"としてお楽しみいただければと思います。

<details><summary>Githubは[こちら](https://github.com/ktkiyoshi/knight_photo_contest)です。</summary>
ただし、時間の関係もあって、じっくりと設計、というわけにはいかなかったこと、`Controller`も`Directive`もすべて一つのJSに書くという至極読みづらい構成になっています笑</details>

## 本システム開発に至った流れ

親友の結婚式二次会の幹事を依頼され、話し合いの中で[「PHOTO SHOWER」](https://photoshower.net)からヒントを得て、フォトコンテストを提案してみました。

二次会参加者の皆様から募った写真を使って、コンテストができたら楽しいんじゃない？という思いつきです。

## フォトコンテスト用システムの概要

### システム概要図

開発完了後に若干手直ししたので、設計図というよりは概要図なのですが、今回の全容です。

![SystemOverview.jpg](https://qiita-image-store.s3.amazonaws.com/0/59926/bbc93c1e-5bde-fc82-229c-77a008fcdd87.jpeg)

### 参加者から写真を集める仕組み

今回序盤で悩んだ部分です。

写真を集める方法はメールだったり、LINEだったり、Twitterだったり、物理メディアデバイスだったり...ただ、どれも手間がかかったり、アカウントなどの制限がつきものです。

そこで今回採用したのは[「Dropboxのファイルリクエスト」](https://www.dropbox.com/help/files-folders/create-file-request)機能です。

![dropbox.png](https://qiita-image-store.s3.amazonaws.com/0/59926/af55b161-2c06-e7e3-a514-8fbf694904d4.png)

手間やアカウントに係る制限を取っ払うためには、**『Dropbox アカウントを所有していない相手にもファイルをリクエストすることができます。』**というのが重要でした。

投稿時にメールアドレスの入力が必須というのが多少引っかかりポイントにはなるかなと思いましたが、実際には特にハードルにはならなかった模様です。（友人の結婚式での企画だし）

### リアルタイムでスライドショーに反映する仕組み

今回のシステムのメインどころですが、**Dropboxを通してローカルPCまで落ちてきた写真をどのようにスライドショーにするか**です。

そもそも、フォトコンテストをするだけならリアルタイムでスクリーンに映し出す必要はなかったのですが、せっかくなら、ということで検討しました。

![angular_white.png](https://qiita-image-store.s3.amazonaws.com/0/59926/3d2aae29-0f11-d80e-468c-ef6dc870ebac.png)

自分の中では紆余曲折あったのですが、最終的にはAngularJSでの実装に踏み切りました。直近の仕事で触っていたこともありましたが、ローカルで動かすことを前提としているので、面倒な環境構築が不要であることなどが理由でした。

## リアルタイムスライドショーの実装

要件としては、**「Dropboxのファイルリクエストで集まってきた写真をローカルで動作しているAngularで参照し、数秒ごとにふわっと切り替えていくこと」**です。

まずは、写真をAngularから参照するためにその情報（ファイル名など）をテキスト化します。もちろんJSON形式です。

```json
[
    {
        "src": "氏 名 - 1458649205435.jpg",
        "title": "氏 名",
        "visible": false
    },
    {
        "src": "氏 名 - IMG_1395.JPG",
        "title": "氏 名",
        "visible": false
    },
//略
]
```

このようなファイルを出力するためのスクリプト（抜粋）が以下です。

```bash
create_json() {
    IMAGE_CNT=`ls ${IMG_DIR} | wc -l`
    CNT=1
    echo "[" > ${OUTPUT_FILE}
    for FILE in `ls -tr ${IMG_DIR}`
    do
        echo "    {" >> ${OUTPUT_FILE}
        echo "        \"src\": \"${FILE}\"," >> ${OUTPUT_FILE}
        echo "        \"title\": \"${FILE% - *}\"," >> ${OUTPUT_FILE}
        echo "        \"visible\": false" >> ${OUTPUT_FILE}
        if [ ${CNT} -ne ${IMAGE_CNT} ];then
            echo "    }," >> ${OUTPUT_FILE}
        else
            echo "    }" >> ${OUTPUT_FILE}
        fi
        CNT=$(( CNT + 1 ))
    done
    echo "]" >> ${OUTPUT_FILE}
}
```

「入賞（選定）した写真を発表する仕組み」や「ランダムで15枚を抽出発表する仕組み」でも同様なことをしているので、Shellでfunction化しています。

当日はこのスクリプトを無限ループで動かし続けるスクリプトを別書きして（最初はcronだったけどやめた）、終わったら1秒sleepして起動、を繰り返しました。

```bash
#!/bin/sh
SCRIPT=$1
CNT=1
while :
do
    sh $SCRIPT
    echo $CNT
    sleep 1
    CNT=$(( CNT + 1 ))
done
```

### スライドショーを映すためのDirective用のテンプレートHTML

先ほどのJSONにある`visible`の値を書き換えて、表示する画像を切り替えていくイメージです。[こちら](http://angularjsninja.com/blog/2013/12/08/angularjs-nganimate/)が参考になりました。

```html
<div class="slider">
    <p ng-repeat="image in images">
        <span class="fade" ng-bind="'By ' + image.title" ng-show="image.visible"></span>
        <img class="fade" ng-src="slideImg/{{image.src}}" ng-show="image.visible" />
    </p>
</div>
```

### 投稿された写真を追加しつつ、写真を切り替えていくDirective本体

ポイントは二点です。

#### 写真の切り替え
スライドショーなので、一定時間ごとに表示する写真を切り替えていく必要があります。そこは`$interval`を使って、4000msごとに切り替えます。切り替え先は`Math.random()`で乱数を発生させてやります。

#### 写真の追加
投稿された写真をバックグラウンドで追加し続けるため、こちらも`$interval`で3000msごとに上記のJSONファイルの最下部のデータを`push`していきます。

無限ループでスクリプトを動かし続けることにより、常に最新の写真を取り込んだJSONを出力し、それを`$interval`で取得し続ける、という仕組みです。

```javascript
.directive('slider', ['$resource', '$interval', function($resource, $interval) {
    return {
        restrict: 'AE',
        replace: true,
        link: function(scope, elem, attrs) {
            // Initial
            scope.dataLoaded = false;
            var data = $resource('json/images.json').query();
            data.$promise.then(function() {
                scope.images = data;
                scope.dataLoaded = true;
            });

            // Add images
            scope.addImages = function() {
                var newData = $resource('json/images.json').query();
                newData.$promise.then(function() {
                    if(scope.images.length < newData.length) {
                        scope.images.push(newData[scope.images.length]);
                    }
                });
            }
            $interval(function() {
                scope.addImages();
            }, 3000);

            // Intial index number
            scope.currentIndex = 0;
            scope.randomIndex = function() {
                scope.currentIndex = Math.floor(Math.random() * (scope.images.length));
                scope.images.forEach(function(image) {
                    image.visible = false;
                });
                scope.images[scope.currentIndex].visible = true;
            };
            // Loop images
            var t = $interval(function() {
                scope.randomIndex();
            }, 4000);
            scope.onclick = function() {
                $interval.cancel(t);
            };

        },
        templateUrl: 'slider.html'
    };
}])
```

ちなみに、最後の方に`scope.onclick`で`$interval`をキャンセルしているけど、これはどこからも呼ばれていないので不要でした。

### 写真の切り替えをふわっとさせる

要するにアニメーションだけど、`angular-animate`を使いました。CSSは以下のような具合でいい感じになります。

```css
.slider .fade.ng-hide-add,
.slider .fade.ng-hide-remove {
    -webkit-transition: all linear 0.5s;
    -moz-transition: all linear 0.5s;
    -o-transition: all linear 0.5s;
    transition: all linear 0.5s;
    display: block!important;
}
.slider .fade.ng-hide-add.ng-hide-add-active,
.slider .fade.ng-hide-remove {
    opacity: 0;
}
.slider .fade.ng-hide-add,
.slider .fade.ng-hide-remove.ng-hide-remove-active {
    opacity: 1;
}
.slider .fade.ng-enter {
    display: none!important;
}
```

最後にある`.fade.ng-enter`の部分が意外と重要で、これがないと新たな写真がバックグラウンドで追加されるたびにAnimationが動いてしまうことになります。

## 当日のスライドショーの様子

中央に投稿された写真が映し出され、右には**「現在の投稿枚数」**と**「投稿先のURL（QRコード）」**を載せているのがわかるかと思います。

投稿枚数もリアルタイム（たぶん投稿して10秒以内）に反映され続けるので、より参加型企画を促進できたのではないか、と。

![IMG_0176.png](https://qiita-image-store.s3.amazonaws.com/0/59926/d1d44792-8e7d-c9f7-a629-98be170e8dd7.png)
※一部加工しています

## コンテストの結果発表の実装

既にJSONを出力する仕組みはあるので、あとは新郎新婦が中盤で選定した写真と、入選した写真以外の写真からランダムで15枚を抽出する仕組みもAngularで実装しました。

### 入賞（選定）した写真を発表する仕組み

要件としては**「5つの賞にそれぞれ1位〜3位があり、各賞の2位3位を同時発表してから、各賞の1位を発表すること」**です。また、5分のリミットで新郎新婦に受賞選定をしてもらうための工夫も必要でした。

予め、"各賞×順位"の階層フォルダを作成しておき、選んでもらった写真をそこに移動するだけで、あとは自動でJSONに出力するという状態にしておきました。（たぶんこれはわかりにくい笑）

![folder_structure.png](https://qiita-image-store.s3.amazonaws.com/0/59926/2e717a87-c748-f096-4ab6-31ca1058e25a.png)

### 各賞発表用のJSONと、それを出力するShell（抜粋）

最初のShellと同じファイルだけど、JSONの形式が異なります。各賞ごとに1位2位3位を持たせるイメージです。（以下では"BEST FRIENDS'"と"MOST FUNNY"が賞名）

```json
{
    "BEST FRIENDS'": [
        {
            "src": "氏 名 - image.jpeg",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - 20141115_155455.jpg",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - 20170205_154931.jpg",
            "title": "氏 名"
        }
    ],
    "MOST FUNNY": [
        {
            "src": "氏 名 - IMG_0617.JPG",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - IMG_7821.JPG",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - IMG_1856.PNG",
            "title": "氏 名"
        }
    ],
// 略
}
```

```bash
create_top3_json() {
    CNT=1
    echo "{" > ${OUTPUT_FILE}
    cd ${IMG_ROOT_DIR}
    for DIR in `ls -d * | grep -v 'Random'`
    do
        echo "    \"${DIR}\": [" >> ${OUTPUT_FILE}
        for NUMBER in 1 2 3
        do
            echo "        {" >> ${OUTPUT_FILE}
            FILE=`ls ${DIR}/${NUMBER}`
            echo "            \"src\": \"${FILE}\"," >> ${OUTPUT_FILE}
            echo "            \"title\": \"${FILE% - *}\"" >> ${OUTPUT_FILE}
            if [ ${NUMBER} -ne 3 ];then
                echo "        }," >> ${OUTPUT_FILE}
            else
                echo "        }" >> ${OUTPUT_FILE}
            fi
        done
        if [ ${CNT} -ne 5 ];then
            echo "    ]," >> ${OUTPUT_FILE}
        else
            echo "    ]" >> ${OUTPUT_FILE}
        fi
        CNT=$(( CNT + 1 ))
    done
    echo "}" >> ${OUTPUT_FILE}
}
```

### ランダムで15枚を抽出発表する仕組み

最後は、全投稿写真から各賞に入選した写真を除いたフォルダを作って、スライドショーと同じスクリプトでJSONファイルを生成しました。そこからランダムで抜き出すのはAngularの役目です。

流れとしては

    1. JSONのデータを読み込む
    2. ソート順をランダムにする
    3. 上から15までをループする
といった感じです。ここではソート順をランダムにするための`service`を紹介。僕も理解はいまいちで、乱数発生させて重み付けを変えているのかな、くらいです笑

```javascript
.service('sharedService', function(){
    return {
        shuffleArray: function(array) {
            return array.map(function(a){return {weight:Math.random(), value:a}}).sort(function(a, b){return a.weight - b.weight}).map(function(a){return a.value});
        }
    }
})
```

## 実装した感想と当日の振り返り

**『とりあえずソースコードきたねえ...』**というのは置いておくとして、我ながらいい感じに動作するものができあがったな、と思います。

### ガッチガチのコーディング

Dropboxのファイルリクエスト機能は、投稿時に氏名をいれるのですが、それが**「氏 名 - ファイル名.jpg」**といった具合になるので、それを分割するために、" - "で切る処理を入れていたりします。

普通に考えたら、JSONで処理するのに超重要なファイル名がユーザからの入力値なのだからある程度のValidation処理やらエラー処理は敷いて然るべきなのかもしれないですが、今回は友人の結婚式ということもあり、スクリプトコードをぶち込んでくるような人はいないと思い、エラーハンドリングはほぼしていません笑

それでも、ある程度の記号文字でのテストだったり、数十枚同時にDropboxに同期されたりしたときの負荷(?)テストなりは実施しておきました。

### 縦長の写真が横になってしまう問題

スマホのポートレートモードで撮影された縦長の写真だった場合などに、その写真がスライドショーに映ると横になってしまう現象が当日発現しました。

根本原因は不明のままだけど、一度PCローカル上で保存し直すと正しく映し出されることがわかったので、デュアルディスプレイの中、手元のモニターで横になってしまう写真を手作業で保存し直してました笑

## さいごに...

Angularも既に1系はその活躍の場を狭め、ReactやVue.jsといったJSフレームワーク（ライブラリ）の台頭も目まぐるしい昨今だからこそ、ちょっと昔を振り返ってみるなどしてみました。