ページ変更ごとにdecorateCookedElement?

Mediaelement.js ライブラリを使用してカスタムオーディオのワンボックスをオーバーライドしようとしていますが、ページが読み込まれたりリロードされたりしたときは機能しますが、別のトピックに移動すると再読み込みされないようで、代わりにネイティブの Discourse オーディオ ワンボックスが表示されます。

読み込み/リロード時:

その後、別のトピックに移動した場合:

mejs の読み込みで何か間違っていると思いますが、そうではないかもしれないと思い、onPageChange などで何か特別なことをする必要があるかと思いました。

head


let loadScript = require('discourse/lib/load-script').default;

    api.decorateCookedElement(
      element => {
        const audioplayers = element.querySelectorAll('audio');
        // console.log("player: " + audioplayers[0]);
        if (Object.entries(audioplayers).length > 0) {
            // console.log("audioplayers has length");
            loadScript(
                `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement.min.js`
            );
            loadScript(
                `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
            );
        }

        audioplayers.forEach(function (el) {
            el.classList.add("mejs__player");
            const controls = settings.theme_uploads.mejs-controls;
            // console.log("controls: " + controls);
            el.setAttribute("data-mejsoptions",
                `{
                    "pluginPath": "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
                    "iconSprite": "https://friends.jimkleiber.com/uploads/default/original/1X/a17d9708a19654d9155dd9b79a79a05dea580067.svg",
                    "alwaysShowControls": "true",
                    "features": ["playpause", "current", "progress", "duration", "volume"]
                }`
            );
            el.setAttribute("preload", "auto");            
        });            
      },
      { id: "mediaelement-js", onlyStream: true}
    );

css

@import "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelementplayer.min.css";

各ページが読み込まれたときに自動的に再読み込みされるように、これを調整する方法について、何か提案はありますか?

これは Development に投稿されたため、少し長めの投稿になります。ただし、非常に一般的な内容であり、統合したいライブラリやターゲットとする投稿要素に関係なく、Discourse で使用するパターンを説明するものです。たまたま、あなたが選んだスクリプトを主な例として使用しています。

MediaElement は、新しいオーディオプレーヤーを初期化するためのいくつかの方法を提供します。

  1. 要素に CSS クラスといくつかの属性を追加すると、自動的に処理されます
  2. 手動で初期化します

現在、あなたは #1 を使用していますので、少しそれについて見てみましょう。スクリプトが要素に対して「自動初期化」を提供する場合、それは通常、スクリプト作成者が追加する利便性の向上機能です。スクリプト内では、通常、ドキュメントの読み込みイベントを監視し、追加を指示されたクラス/属性を持つ DOM 要素に対して何らかの処理を行います。

さて、なぜ動作しないのでしょうか?ご存知の通り、初期ページ読み込み時には問題なく動作しますが、アプリ内のナビゲーション後の動作では機能しません。何が起きているのでしょうか?

短い答えは、Discourse がシングルページアプリケーションであることです。
要素である <HTML><body> タグは一度だけ送信されます。つまり、ある意味でドキュメントは一度だけ読み込まれます。したがって、初期ページ読み込み後にナビゲートしても、ネイティブな「load」イベントは送信されません。ドキュメントは初期ページ表示時にすでに読み込まれていることを覚えておいてください。その後に起こるすべてのことは Discourse によって処理されます。

もちろん、その後のナビゲーションでイベントが発火しないわけではありません。ただし、それらは Discourse 固有のイベントです。したがって、サードパーティのスクリプト作成者は事前にそれらを知る方法がありません。スクリプト作成者として、100 以上の異なるプラットフォームに対応しなければならない状況を想像してみてください。良くないですよね?

つまり、スクリプト作成者が追加してくれた利便性の高い方法は使えません。次にどうするか?さて、ターゲット要素に対してスクリプトを手動で初期化できることを思い出してください。それで、それを試してみましょう。

以前、ネイティブ(ブラウザレベル)の load イベントは 1 つしかないと言いましたが、Discourse のようなプラットフォームは、独自のイベントシステムを持たなければ機能しません。例えば、プラグイン API には、バーチャルページナビゲーションでスクリプトを発火させる メソッドがあります。

そのメソッドを使用すべきでしょうか?いいえ。そのメソッドは分析などの用途には非常に役立ちますが、<audio> タグのみを処理するスクリプトをすべてのページで発火させる意味はありません。特に、そのタグが存在しないページの場合です。

では、次は何でしょうか?好消息は、あなたがすでに答えを見つけ出していることです。decorateCookedElement がここで使用する正しいメソッドです。

これにより、何ができるかと言いますと…待ってください…投稿を装飾できます :tada:

Discourse は、追加するすべての装飾がすべての投稿に適用されることを保証します。

さて、あなたは投稿装飾内でスクリプトを読み込んでいますので、追加され、動作するはずです。なぜ後続のナビゲーションでは動作しないのでしょうか?

これについては、loadScript() の仕組みを理解する必要があります。あなたのコードは、スクリプトを読み込む前に有効なターゲット要素があるかチェックしていますので、素晴らしいです :+1:

ただし、20〜30 件の投稿が連続してあり、それぞれに有効な要素が含まれている状況を想像してください。スクリプトを 20〜30 回読み込むことに意味があるでしょうか?明らかにありません。

loadScript() は、スクリプトがすでに読み込まれているかを検知するほど賢くできています。重複を読み込むことはなく、すでにダウンロードが完了しているスクリプトを再読み込みすることもありません。ここで見ることができます。

上記の fullUrl は、loadScript() を呼び出す際に渡す URL です。あなたの例と同じです。

さて、これを知ったことで、なぜ後続のナビゲーションで動作しないかがなんとなくわかります。

トピック A を訪問 > オーディオ要素がある > loadScript() がスクリプトを読み込む > スクリプトが「自動初期化」の特別な処理を行う > スクリプトがあなたの要素を初期化する > カスタムオーディオ要素が表示される

そして...

トピック B を訪問 > オーディオ要素がある > loadScript() がスクリプトがすでに読み込まれていると検知 > 「自動初期化」の特別な処理は行われない > デフォルトのオーディオ要素が表示される > 悲しみが訪れる

では、これをどう修正すればよいでしょうか?それは、前述の #2 の出番です。

  1. 要素に CSS クラスといくつかの属性を追加すると、自動的に処理されます

  2. 手動で初期化します

それでは、それを行いましょう。これは、あなたが共有したページ にすでにドキュメント化されています。ターゲット要素に対して以下のように呼び出す必要があります。

// プレーヤー ID の文字列(例:`player`)を使用することも、
// `document.querySelector()` で任意のセレクターを使用することもできます
var player = new MediaElementPlayer("player", {
  // ... オプション
});

あなたのコードはすでに各オーディオ要素を個別に処理しています :+1: ので、これを以下のように修正するだけです。

audioplayers.forEach(function (el) {
  el.classList.add("mejs__player");
  const controls = settings.theme_uploads.mejs - controls;
  // console.log("controls: " + controls);
  el.setAttribute(
    "data-mejsoptions",
    `{
                    "pluginPath": "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
                    "iconSprite": "https://friends.jimkleiber.com/uploads/default/original/1X/a17d9708a19654d9155dd9b79a79a05dea580067.svg",
                    "alwaysShowControls": "true",
                    "features": ["playpause", "current", "progress", "duration", "volume"]
                }`
  );
  el.setAttribute("preload", "auto");
});

これを以下のように変更します。

audioplayers.forEach(function (el) {
-- el.classList.add("mejs__player");
-- const controls = settings.theme_uploads.mejs - controls;
-- // console.log("controls: " + controls);
-- el.setAttribute(
--   "data-mejsoptions",
--   `{
--                     "pluginPath": "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
--                     "iconSprite": "https://friends.jimkleiber.com/uploads/default/original/1X/a17d9708a19654d9155dd9b79a79a05dea580067.svg",
--                     "alwaysShowControls": "true",
--                     "features": ["playpause", "current", "progress", "duration", "volume"]
--                 }`
-- );
-- el.setAttribute("preload", "auto");
++ 
++ new MediaElementPlayer(el, {
++  // ... オプション
++ });
});

その後、以前属性として追加していたオプションをオブジェクトとして以下のように渡します。

audioplayers.forEach(function (el) {
 new MediaElement(el, {
++ pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++ iconSprite: settings.theme_uploads["mejs-controls"],
++ alwaysShowControls: "true",
++ features: ["playpause", "current", "progress", "duration", "volume"]
 });
});

これを一旦保留にして、残りの装飾を見てみましょう。現時点でのコードは以下の通りです。

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
    if (Object.entries(audioplayers).length > 0) {
      // console.log("audioplayers has length");
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement.min.js`
      );
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

お気づきの通り、2 つの異なるスクリプトに対して loadScript() を呼び出しています。これが意図的かどうかはわかりませんが、必要なのは片方だけです。フルバンドルと軽量版だと考えてください。カスタムオーディオプレーヤーが必要なので、フルバンドルが必要です。もう一方を削除しましょう。

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
    if (Object.entries(audioplayers).length > 0) {
      // console.log("audioplayers has length");
--    loadScript(
--      `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement.min.js`
--    );
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

投稿内にオーディオプレーヤーがあるかチェックし、それに基づいて条件付きでスクリプトを読み込んでいます。これは以下のように簡略化できます。まず、長さを直接チェックします。

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
--    if (Object.entries(audioplayers).length > 0) {
++    if (audioplayers.length) {
      // console.log("audioplayers has length");

      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

その後、それをトップに移動し、長さが偽(length < 0)の場合は単に return します。また、コード内のコメントも削除しました。

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

++  if (!audioplayers.length) {
++   return;
++  }

    loadScript(
      `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
    );

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

スクリプトの src は決して変わらないので、それを const に移動しましょう。loadScript() も常に同じなので、これも const にします。

++ const loadScript = require("discourse/lib/load-script").default;

++ const MEDIA_ELEMENT_SRC =
++  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

++  loadScript(MEDIA_ELEMENT_SRC);

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

これも一旦保留にします。続ける前に、もう少し loadScript() の仕組みについて話す必要があります。

スクリプトの一部を使用したい場合、作業を行う前にそれが読み込まれていることを確認したいですよね?loadScript() はそれを処理してくれます。これは Promise を返します。Promise は最初は怖く聞こえますが、実はとてもシンプルです。Promise は文字通り… 約束 です。

何か作業をしたい…ブラウザに作業が完了したら知らせると約束する…ブラウザはそれを待つ。実はそれだけなのです。残りは構文を理解するだけです。

これはこのトピックの範囲外なので、あまり時間をかけません。

続けましょう。loadScript() は Promise ベースです。Discourse はブラウザに、スクリプトが完全に読み込まれたことを知らせると約束します。スクリプトが存在せず読み込む必要がある場合でも、すでに読み込まれているかを確認する場合でもです。

つまり、以下のようなことをします。

const loadScript = require("discourse/lib/load-script").default;

const MEDIA_ELEMENT_SRC =
  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

++  loadScript(MEDIA_ELEMENT_SRC).then(() => {
++    // これはスクリプトが読み込まれた/読み込まれている場合のみ発火します
++    console.log("my script has loaded");
++  });

    // forEach は以前ここにありました
  },
  { id: "mediaelement-js", onlyStream: true }
);

これで、以前の forEach ループに戻り、その中に追加すれば、スクリプトが利用可能であることを確実に知ることができます。

const loadScript = require("discourse/lib/load-script").default;

const MEDIA_ELEMENT_SRC =
  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

    loadScript(MEDIA_ELEMENT_SRC).then(() => {
++    audioplayers.forEach(function (el) {
++      new MediaElementPlayer(el, {
++        pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++        iconSprite: settings.theme_uploads["mejs-controls"],
++        alwaysShowControls: "true",
++        features: ["playpause", "current", "progress", "duration", "volume"]
++      });
++    });
    });
  },
  { id: "mediaelement-js", onlyStream: true }
);

さて、上記のスニペットを見て、気になる点はありますか…

スクリプトインスタンスに渡すオプションは常に同じですが、const に入れていません。それを修正しましょう。

    const loadScript = require("discourse/lib/load-script").default;
    
    const MEDIA_ELEMENT_SRC =
      "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";
    
++  const MEDIA_ELEMENT_CONFIG = {
++    pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++    iconSprite: settings.theme_uploads["mejs-controls"],
++    alwaysShowControls: "true",
++    features: ["playpause", "current", "progress", "duration", "volume"]
++  };
    
    api.decorateCookedElement(
      element => {
        const audioplayers = element.querySelectorAll("audio");
    
        if (!audioplayers.length) {
          return;
        }
    
        loadScript(MEDIA_ELEMENT_SRC).then(() => {
          audioplayers.forEach(function (el) {
++          new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
          });
        });
      },
      { id: "mediaelement-js", onlyStream: true }
    );

これでほぼ完了です。あなたのコードは非常に近かったです。loadScript() をもう少し理解するだけで済みました。

これで、すべてをまとめましょう。

common > header タブ

<script type="text/discourse-plugin" version="0.8.42">
  const loadScript = require("discourse/lib/load-script").default;

  const MEDIA_ELEMENT_SRC =
    "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

  const MEDIA_ELEMENT_CONFIG = {
    pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
    iconSprite: settings.theme_uploads["mejs-controls"],
    alwaysShowControls: "true",
    features: ["playpause", "current", "progress", "duration", "volume"]
  };

  api.decorateCookedElement(
    element => {
      const audioplayers = element.querySelectorAll("audio");

      if (!audioplayers.length) {
        return;
      }

      loadScript(MEDIA_ELEMENT_SRC).then(() => {
        audioplayers.forEach(function (el) {
          new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
        });
      });
    },
    { id: "mediaelement-js", onlyStream: true }
  );
</script>

次に、スクリプトの CSS を読み込み、スクリプトが要素を交換する際のちらつきを防ぐための少しの CSS を追加します。

common > css

@import "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelementplayer.min.css";

.cooked {
  --audio-player-height: 40px;

  .mejs__container,
  audio {
    // ちらつきを防ぐために Media-Element.js プレーヤーの高さを合わせる
    height: var(--audio-player-height);
    display: block;
  }
}

これで、有効な要素を持つすべての投稿にカスタムプレーヤーが表示されるはずです。

これで片付いたので、あなたが選んだライブラリは非常に古いことに注意してください。これは古いブラウザ向けにトランスパイルされており、その後標準となった多くの機能をポリフィルしようとしています。

それを使う理由がわかっているなら、問題ありません。しかし、プレーヤーの外観をカスタマイズするためだけに使っているなら、避けることをお勧めします。確認していませんが、もっと軽量な現代的な代替案があるはずです。

これらすべての最大の利点は、実装が上記から変わらないことです。ターゲットとする要素や使用するスクリプトが何であっても、同じパターンが適用されます。変化する唯一のことは、カスタムスクリプトを初期化することです。まともなライブラリはすべて、それをガイドする非常に良いドキュメントを持っています。その後、それを上記のパターンに組み込むだけです。

このような瞬間に、Metaがhttps://meta.discourse.org/t/discourse-reactions-beyond-likes/183261を使用していればと願うばかりです。なぜなら、今の私の愛情と純粋な感謝の気持ちを、ハートマークだけでは表現しきれないからです。

誰かが少なくともヒントをくれるだけでもいいと思っていたのに、あなたが時間をかけて、私がどうしてその状況に至ったのかを非常に詳細に説明し、それが機能するように導いてくれました(ジッターに関する些細な詳細まで!)。

Discourseの構造がそのような行動を奨励するのに役立つ方法を学んでいます。つまり、一度うまく答えれば、他の人もそれを見ることができ、私が何度も答える必要がなくなるということです。これは、これを使ってコミュニティを構築し続けることを奨励してくれます。しかし、それがあなたがこれを私に書いてくれた理由を完全に説明しているとは思いませんし、そうしてくれたあなたの意欲は、私がこのプラットフォームをさらに活用することを奨励するかもしれません。

ありがとうございます。


Mediaelementについては、ええ、古いですが、私が持っているWordPressサイトとよく合い、そこでかなりのカスタマイズを加えて、ユーザーに見慣れた外観を提供しようとしています(そして、現時点では別のライブラリを学ぶ気もありません :smiley:)。

フォローアップと、おそらく愚かな質問ですが:プラグインスクリプトを許可するMediaelementで、複数のスクリプトをロードしようとしています。Promiseを返す前に、すべてのスクリプトがロードされることを確認したいです。

スクリプトソースの定数をループ処理し、Promiseの配列を作成してから、プレーヤーの初期化にPromise.all()を使用しようとしましたが、mejsが見つからないというエラーが発生します。これは、mediaelement-and-player内のさまざまな関数を呼び出すための名前空間または何かだと思います。

      let scripts_loaded = [];

      MEDIA_ELEMENT_SCRIPTS.forEach(function (src, index){
          scripts_loaded[index] = loadScript(src);
      });

      Promise.all(scripts_loaded).then(() => {
            audioplayers.forEach(function (el) {
              new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
            });
      });

しかし、手動でチェーンすると、次のように機能するようです。

      loadScript(MEDIA_ELEMENT_SRC).then(() => {
          loadScript(MEDIA_ELEMENT_SPEED_SRC).then(() => {
            loadScript(MEDIA_ELEMENT_SKIP_BACK_SRC).then(() => {
                loadScript(MEDIA_ELEMENT_JUMP_FORWARD_SRC).then(() => {
                    audioplayers.forEach(function (el) {
                        new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
                    });
                });
            });
        });
      });

このインスタンスでは、スクリプトを数個しか使用していないため、すべて手動で入力しましたが、Promise.all()関数について何か明白なことを見落としているのか、または配列から複数のスクリプトをロードできるDiscourse関数があるのかどうか疑問に思いました。

あなたのコードは正常に動作するはずです。Discourse のバグに偶然遭遇しただけです。

loadScript() は、ロードするスクリプトに async 属性を設定しません。そのため、デフォルトで async="true" となり、ロード順序が狂ってしまいます。これはブラウザの仕様です。JS でロードされるスクリプトには、async="false" を強制する必要があります。

プラグインはサイズが小さいため、メインバンドルよりも速くロードされますが、async であるため、メインバンドルのロードと実行を待ってから実行するというロード順序が尊重されなくなります。

loadScript は、私の知る限り、コアのどこにもネストされていないため、おそらく見過ごされていました。通常、連携して動作する必要があるファイルはバンドルします。したがって、もう一つの質問にお答えすると、そのようなことを処理する Discourse の関数はありません。

もう一方のスニペットも機能するはずです。読みやすくするために、ネストせずにチェーン化することを試してみてはどうでしょうか。

// これはデコレーターの外側に記述します
const PLUGINS = {
  speed: "https://example.com/foo.js",
  skipBack: "https://example.com/bar.js",
  jumpForward: "https://example.com/baz.js"
};

// その後、デコレーター内で作業を行います
loadScript(MEDIA_ELEMENT_SRC)
  .then(() => loadScript(PLUGINS.speed))
  .then(() => loadScript(PLUGINS.skipBack))
  .then(() => loadScript(PLUGINS.jumpForward))
  .then(() => {
    audioplayers.forEach(function (el) {
      new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
    });
  });

ああ、教えてくれてありがとうございます!

わかりました、了解です。

わあ、本当に感謝しています。ずっとすっきりしました。改めてありがとうございます :pray: