これは Development に投稿されたため、少し長めの投稿になります。ただし、非常に一般的な内容であり、統合したいライブラリやターゲットとする投稿要素に関係なく、Discourse で使用するパターンを説明するものです。たまたま、あなたが選んだスクリプトを主な例として使用しています。
MediaElement は、新しいオーディオプレーヤーを初期化するためのいくつかの方法を提供します。
要素に CSS クラスといくつかの属性を追加すると、自動的に処理されます
手動で初期化します
現在、あなたは #1 を使用していますので、少しそれについて見てみましょう。スクリプトが要素に対して「自動初期化」を提供する場合、それは通常、スクリプト作成者が追加する利便性の向上機能です。スクリプト内では、通常、ドキュメントの読み込みイベントを監視し、追加を指示されたクラス/属性を持つ DOM 要素に対して何らかの処理を行います。
さて、なぜ動作しないのでしょうか?ご存知の通り、初期ページ読み込み時には問題なく動作しますが、アプリ内のナビゲーション後の動作では機能しません。何が起きているのでしょうか?
短い答えは、Discourse がシングルページアプリケーションであることです。
要素である <HTML> や <body> タグは一度だけ送信されます。つまり、ある意味でドキュメントは一度だけ読み込まれます。したがって、初期ページ読み込み後にナビゲートしても、ネイティブな「load」イベントは送信されません。ドキュメントは初期ページ表示時にすでに読み込まれていることを覚えておいてください。その後に起こるすべてのことは Discourse によって処理されます。
もちろん、その後のナビゲーションでイベントが発火しないわけではありません。ただし、それらは Discourse 固有のイベントです。したがって、サードパーティのスクリプト作成者は事前にそれらを知る方法がありません。スクリプト作成者として、100 以上の異なるプラットフォームに対応しなければならない状況を想像してみてください。良くないですよね?
つまり、スクリプト作成者が追加してくれた利便性の高い方法は使えません。次にどうするか?さて、ターゲット要素に対してスクリプトを手動で初期化できることを思い出してください。それで、それを試してみましょう。
以前、ネイティブ(ブラウザレベル)の load イベントは 1 つしかないと言いましたが、Discourse のようなプラットフォームは、独自のイベントシステムを持たなければ機能しません。例えば、プラグイン API には、バーチャルページナビゲーションでスクリプトを発火させる メソッドがあります。
そのメソッドを使用すべきでしょうか?いいえ。そのメソッドは分析などの用途には非常に役立ちますが、<audio> タグのみを処理するスクリプトをすべてのページで発火させる意味はありません。特に、そのタグが存在しないページの場合です。
では、次は何でしょうか?好消息は、あなたがすでに答えを見つけ出していることです。decorateCookedElement がここで使用する正しいメソッドです。
これにより、何ができるかと言いますと…待ってください…投稿を装飾できます
Discourse は、追加するすべての装飾がすべての投稿に適用されることを保証 します。
さて、あなたは投稿装飾内でスクリプトを読み込んでいますので、追加され、動作するはずです。なぜ後続のナビゲーションでは動作しないのでしょうか?
これについては、loadScript() の仕組みを理解する必要があります。あなたのコードは、スクリプトを読み込む前に有効なターゲット要素があるかチェックしていますので、素晴らしいです
ただし、20〜30 件の投稿が連続してあり、それぞれに有効な要素が含まれている状況を想像してください。スクリプトを 20〜30 回読み込むことに意味があるでしょうか?明らかにありません。
loadScript() は、スクリプトがすでに読み込まれているかを検知するほど賢くできています。重複を読み込むことはなく、すでにダウンロードが完了しているスクリプトを再読み込みすることもありません。ここで見ることができます。
const fullUrl = opts.css ? getURL(url) : getURLWithCDN(url);
$("script").each((i, tag) => {
const src = tag.getAttribute("src");
if (src && src !== fullUrl && !_loading[src]) {
_loaded[src] = true;
}
});
return new Promise(function (resolve) {
// If we already loaded this url
if (_loaded[fullUrl]) {
return resolve();
}
上記の fullUrl は、loadScript() を呼び出す際に渡す URL です。あなたの例と同じです。
さて、これを知ったことで、なぜ後続のナビゲーションで動作しないかがなんとなくわかります。
トピック A を訪問 > オーディオ要素がある > loadScript() がスクリプトを読み込む > スクリプトが「自動初期化」の特別な処理を行う > スクリプトがあなたの要素を初期化する > カスタムオーディオ要素が表示される
そして...
トピック B を訪問 > オーディオ要素がある > loadScript() がスクリプトがすでに読み込まれていると検知 > 「自動初期化」の特別な処理は行われない > デフォルトのオーディオ要素が表示される > 悲しみが訪れる
では、これをどう修正すればよいでしょうか?それは、前述の #2 の出番です。
要素に CSS クラスといくつかの属性を追加すると、自動的に処理されます
手動で初期化します
それでは、それを行いましょう。これは、あなたが共有したページ にすでにドキュメント化されています。ターゲット要素に対して以下のように呼び出す必要があります。
// プレーヤー ID の文字列(例:`player`)を使用することも、
// `document.querySelector()` で任意のセレクターを使用することもできます
var player = new MediaElementPlayer("player", {
// ... オプション
});
あなたのコードはすでに各オーディオ要素を個別に処理しています ので、これを以下のように修正するだけです。
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;
}
}
これで、有効な要素を持つすべての投稿にカスタムプレーヤーが表示されるはずです。
これで片付いたので、あなたが選んだライブラリは非常に古いことに注意してください。これは古いブラウザ向けにトランスパイルされており、その後標準となった多くの機能をポリフィルしようとしています。
それを使う理由がわかっているなら、問題ありません。しかし、プレーヤーの外観をカスタマイズするためだけに使っているなら、避けることをお勧めします。確認していませんが、もっと軽量な現代的な代替案があるはずです。
これらすべての最大の利点は、実装が上記から変わらないことです。ターゲットとする要素や使用するスクリプトが何であっても、同じパターンが適用されます。変化する唯一のことは、カスタムスクリプトを初期化することです。まともなライブラリはすべて、それをガイドする非常に良いドキュメントを持っています。その後、それを上記のパターンに組み込むだけです。