Log

いろいろ

IntersectionObserverではてなブログ記事内のApple Musicを自動再生する

Twitterに要件定義が転がっていたのでIntersectionObserverを用いて実装しました。

github.com

以下のコードを記事のどこかに貼り付けると、画面右下に自動再生ボタンが表示されます。自動再生ボタンを押すと、画面で見えている「iTunes商品紹介」を自動で再生するようになります。

<span></span><style>
    #autoplay-btn{color:gainsboro;background-color:#fff;background-size:cover;position:fixed;bottom:5%;right:10%;width:60px;height:60px;border:5px solid currentColor;border-radius:100%;cursor:pointer;z-index:10000}#autoplay-btn.disabled{opacity:.3;cursor:not-allowed}#autoplay-btn:before,#autoplay-btn:after{content:"";position:absolute;top:10px;left:30%;border-top:20px solid transparent;border-left:35px solid currentColor;border-bottom:20px solid transparent}#autoplay-btn.autoplay{border-style:inset}#autoplay-btn.autoplay:before,#autoplay-btn.autoplay:after{opacity:.5;height:40px;border-width:0 6px 0 6px;border-color:transparent currentColor transparent currentColor}#autoplay-btn.autoplay:after{left:60%}
</style>
<div id="autoplay-btn" class="disabled"></div>
<script>
let observer=null,autoplay=!1;document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("autoplay-btn");e.classList.remove("disabled"),e.addEventListener("click",e=>{autoplay?autoplayOff(e.target):autoplayOn(e.target)});let t=new IntersectionObserver(t=>{t.forEach(t=>{t.isIntersecting?e.style.display="block":(autoplayOff(e),e.style.display="none")})});t.observe(e.parentElement)});const autoplayOn=e=>{autoplay=!0,e.classList.add("autoplay");let t=e.parentNode.getElementsByTagName("audio");for(let a of t)a.load(),a.loop=!0;for(let o of(observer=new IntersectionObserver(a=>{let o=null;if(a.forEach(e=>{(!e.target.paused||e.isIntersecting)&&(o=e.target)}),null!==o){for(let l of t)l.isSameNode(o)||l.pause();o.play();let s=o.closest(".itunes-embed").querySelector("img");e.style.backgroundImage=s?`url(${s.getAttribute("src")})`:"none"}},{rootMargin:"-30% 0px"}),t))o.load(),observer.observe(o)},autoplayOff=e=>{autoplay=!1,e.classList.remove("autoplay"),e.style.backgroundImage="none",observer&&observer.disconnect();let t=e.parentNode.getElementsByTagName("audio");for(let a of t)a.pause()};
</script>

再生中

俺のPCと俺のスマホの俺のブラウザで動作します。また、はてなブログの埋め込み機能に依存しているため、そのうち動かなくなるかもしれません。

この記事には合間に楽曲を配置しておきます。せっかくなので自動再生してみてね。

ぶっ壊れてもいいから導入を楽にする

Distortion!!

Distortion!!

  • 結束バンド
  • アニメ
  • ¥255

iTunes商品紹介」に依存している理由は導入時のハードルを下げるためです。

例えばdivで囲むとかめんどうじゃないですか?

Distortion!! / 結束バンド
<div class="autoplay">
[https://music.apple.com/jp/album/distortion/1646030812?i=1646030817&uo=4:embed]
</div>

Distortion!!は素晴らしいので素晴らしいですね。Distortion!!は〜

共有の埋め込みコードに手を加えるなんてもってのほか。「コードをコピペすれば自動再生ができるようになる」というのが一番楽だなと思ってこの方針にしました。

次節以降に実装の詳細は載せますが、再生コンテンツはaudio要素で特定しています。そうすると余計なaudio要素が混入する可能性があるので、自動再生ボタンの親ノード配下のみを対象とすることで、お気持程度ですが意図せぬコンテンツの再生を防ぎます。

const audios = btn.parentNode.getElementsByTagName("audio");

CSSとHTML

なにが悪い

なにが悪い

  • 結束バンド
  • アニメ
  • ¥255

ふいんきで書いたのでとくに触れることはありません。

<span></span><style>
    #autoplay-btn {
        color: gainsboro;
        background-color: white;
        background-size: cover;
        position: fixed;
        bottom: 5%;
        right: 10%;
        width: 60px;
        height: 60px;
        border: 5px solid currentColor;
        border-radius: 100%;
        cursor: pointer;
        z-index: 10000;
    }
    #autoplay-btn.disabled {
        opacity: 0.3;
        cursor: not-allowed;
    }
    #autoplay-btn:before,
    #autoplay-btn:after {
        content: "";
        position: absolute;
        top: 10px;
        left: 30%;
        border-top: 20px solid transparent;
        border-left: 35px solid currentColor;
        border-bottom: 20px solid transparent;  
    }
    #autoplay-btn.autoplay {
        border-style: inset;
    }
    #autoplay-btn.autoplay:before,
    #autoplay-btn.autoplay:after {
        opacity: 0.5;
        height: 40px;
        border-width: 0 6px 0 6px;
        border-color: transparent currentColor transparent currentColor;
    }
    #autoplay-btn.autoplay:after {
        left: 60%;
    }
</style>
<div id="autoplay-btn" class="disabled"></div>
<script>
    <!-- ここにJavaScriptを書く -->
</script>

CSSにコメントを書いたり空行を混入させたりすると、意図せぬ動作となることがあったので省いています。先頭の謎のspan要素も記事内でstyleタグを読み込ませるために必要です。このようなはてなブログ独自のものはとくに深掘りしません。

ボタンの活性化

ダイスキ。

ダイスキ。

let observer = null;
let autoplay = false;

document.addEventListener("DOMContentLoaded", () => {
    const autoplayBtn = document.getElementById("autoplay-btn");
    autoplayBtn.classList.remove("disabled");
    autoplayBtn.addEventListener("click", (e) => {
        autoplay ? autoplayOff(e.target) : autoplayOn(e.target);
    });

    // 記事がビューポートから出たら自動再生ボタンを非表示にする
    const autoplayBtnObserver = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
            if (entry.isIntersecting) {
                autoplayBtn.style.display = "block";
            } else {
                autoplayOff(autoplayBtn);
                autoplayBtn.style.display = "none";
            }
        });
    });
    autoplayBtnObserver.observe(autoplayBtn.parentElement);
})

生のJavaScriptで状態をどのように保持したらいいのかよく分かりません。Reactがいないと私は何もできない…Reactがダイスキ。

ボタンの活性化はaudio要素のマウントを待ちたいので、DOMContentLoadedが発火してからeventListenerを付与し、disabled状態を解除します。

あとで出てくるIntersectionObserverをここでも利用して、記事がビューポートを出たら自動再生ボタンを非表示としています。

ブラウザによるaudio要素の自動再生ブロック

23時の春雷少女

23時の春雷少女

autoplayOnの途中まで。

const autoplayOn = (btn) => {
    autoplay = true;
    btn.classList.add("autoplay");

    // ユーザーアクションからloadすることで、JSでのaudio.play()を有効にする
    const audios = btn.parentNode.getElementsByTagName("audio");
    for (const audio of audios) {
        audio.load();
        audio.loop = true;
    };

ブラウザによっては、audio要素のautolplay機能およびJavaScriptからのplayメソッドによる自動再生を制限しています。

メディアおよびウェブ音声 API の自動再生ガイド - ウェブメディア技術 | MDN

Chromeでのエラー。

Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.

これを避けるために「自動再生ボタンをクリック」というユーザーアクションをトリガーにすべてのaudio要素をloadしておくことで、以降のIntersectionObserverのコールバック内でplayメソッドを利用できるようにしています。

ループ処理は久々にfor...ofで書きました。querySelectorAllの戻り値はNodeListであり、配列ではないけどforEachが利用できるのに対して、getElementsByTagNameの戻り値はHTMLCollectionであり、forEachが実装されていないので渋々です。

IntersectionObserver

Distortion!!

Distortion!!

  • 結束バンド
  • アニメ
  • ¥255

バグではなく普通に2回目のDistortion!!です。autoplayOnの続き。

    observer = new IntersectionObserver((entries) => {
        let activeAudio = null;
        entries.forEach((entry) => {
            // 再生中または交差中のうち最後尾の要素を再生対象とする
            if (!entry.target.paused || entry.isIntersecting) {
                activeAudio = entry.target;
            }
        });
        if (activeAudio !== null) {
            for (const audio of audios) !audio.isSameNode(activeAudio) && audio.pause();
            activeAudio.play();

            // 自動再生ボタンの背景画像を設定する
            const img = activeAudio.closest(".itunes-embed").querySelector("img");
            btn.style.backgroundImage = img
                ? `url(${img.getAttribute("src")})`
                : "none";
        }
    }, {
        rootMargin: "-30% 0px"
    });
    for (const audio of audios) {
        audio.load();
        observer.observe(audio);
    };

今回の主役です。

IntersectionObserver - Web API | MDN

第二引数を渡さない場合、ビューポート内にaudio要素が出現するとコールバックしますが、それだとフライングしてしまう可能性があります。

Make Debut!を読んでいるのに不意に熱い鼓動が目覚めてしまう

そこで、rootMargin: "-30% 0px"を指定することで、ビューポートを3割超過したあたりでコールバックするよう判定ラインを調整しています。ただ、上から読んでいたらフライング問題はあまり発生しないかもしれません。

このoptionsですが、はてなブログプレビュー画面では有効にならなかったため、めちゃくちゃ時間を溶かしました。疲れたので原因は調べていません。プレビュー画面はiframeで埋め込まれているので動作が違うのでしょう。公開後はちゃんと機能しているのでもういいです。*1

停止処理

White ambitions

White ambitions

ただの停止処理です。

const autoplayOff = (btn) => {
    autoplay = false;
    btn.classList.remove("autoplay");
    btn.style.backgroundImage = "none";
    observer && observer.disconnect();
    const audios = btn.parentNode.getElementsByTagName("audio");
    for (const audio of audios) audio.pause();
};

実装はおしまい!

シャイニングスター

シャイニングスター

  • 魔王魂 & 森田交一
  • J-Pop
  • ¥255

実際に導入するのであれば

  • 複数記事が同時に1ページに表示されないようにする
  • コードを記事ではなくブログのheadに追加する

などの対応をした方がいいです。

前者。例えば私のはてなブログ記事のトップページには複数の記事が表示されますが、ボタンのidが一意とならないため、ぶっ壊れるかも知れません。はてなブログProだとトップページに表示する記事を1つにできた気がします。

後者。ぶっ壊れたときにすべての記事のコードを修正するのはありえないので、head内にぶちこむなどを検討しなければいけません。はてなブログProだとPC・スマホのどちらでも共有スクリプトを配置できた気がします。

そういえば今日は私の誕生日です、はてなブログProのプレゼントを心よりお待ちしております。

2023/2/13:追記

要件定義元のめがねこさんが、私が勝手に実装した自動再生ボタンを自身のブログに配置してくれました。なんて心の広い。こちらも見てね。

最近聴いている曲2023年2月の部 その1 - ねこおきば

あと、せっかくなので私のブログにも配置しました。過去の音楽関連の記事はなるたけiTunes商品紹介に差し替えておきます。こちらも見てね。

2022年好きな楽曲10選 - Log

*1:バグがあるシステムもリリースしたらなおってるかも…?