Mediaelement.js 라이브러리를 사용해 커스텀 오디오 원박스(Onebox)를 오버라이드하려고 하고 있습니다. 페이지가 로드되거나 새로고침될 때는 정상적으로 작동하지만, 한 주제에서 다른 주제로 이동하면 새로 로드되지 않고 대신 Discourse의 기본 오디오 원박스가 표시됩니다.
Development 채널에 게시물을 올려주셨기 때문에 이 글은 다소 길어질 것입니다. 하지만 내용은 매우 일반적이며, 통합하려는 라이브러리나 대상이 되는 게시물 요소와 관계없이 Discourse에서 사용할 패턴을 설명하는 데 중점을 둡니다. 단지 주된 예제로 여러분이 선택하신 스크립트를 사용했을 뿐입니다.
MediaElement는 새로운 오디오 플레이어를 초기화하는 몇 가지 방법을 제공합니다.
요소에 CSS 클래스와 몇 가지 속성을 추가하면 자동으로 처리해 줍니다.
수동으로 초기화합니다.
현재 #1번 방법을 사용 중이므로 잠시 그 부분을 살펴보겠습니다. 스크립트가 “요소 자동 초기화” 기능을 제공하는 경우, 이는 일반적으로 스크립트 작성자가 사용자 편의성을 위해 추가하는 기능입니다. 스크립트 내부에서는 보통 document load 이벤트를 듣고, 여러분이 추가하라고 안내한 클래스/속성을 가진 DOM 요소에 대해 작업을 수행합니다.
그렇다면 왜 동작하지 않는 걸까요? 확인하셨듯이, 초기 페이지 로드 시에는 잘 동작하지만 앱 내 후속 내비게이션에서는 동작하지 않습니다. 이유가 뭘까요?
간단한 답은 Discourse가 단일 페이지 애플리케이션(SPA)이라는 점입니다. <HTML>과 <body> 태그와 같은 요소는 한 번만 전송됩니다. 즉, 일종의 의미에서 document는 한 번만 로드됩니다. 따라서 초기 페이지 로드 이후 내비게이션을 수행할 때 더 이상 네이티브 “load” 이벤트가 발생하지 않습니다. 기억하세요, document는 이미 초기 페이지 뷰 시점에 로드되었습니다. 그 이후에 발생하는 모든 것은 Discourse가 처리합니다.
물론 후속 내비게이션 시 이벤트가 발생하지 않는다는 뜻은 아닙니다. 하지만 그것은 Discourse 고유 이벤트입니다. 따라서 제3자 스크립트 작성자는 이를 사전에 알 수 있는 방법이 없습니다. 스크립트 작성자로서 100개 이상의 다양한 플랫폼을 모두 고려해야 한다면? 불가능하겠죠?
따라서 스크립트 작성자가 친절하게 추가해 준 사용자 편의성 방식은 사용할 수 없습니다. 다음 단계는 무엇일까요? 앞서 언급했듯이, 대상 요소에서 스크립트를 수동으로 초기화할 수 있습니다. 그래서 그렇게 해보겠습니다.
앞서 네이티브(브라우저 레벨) load 이벤트는 단 하나뿐이라고 언급했지만, Discourse와 같은 플랫폼은 자체 이벤트 시스템이 없다면 제대로 작동하지 못할 것입니다. 예를 들어, 플러그인 API에는 가상 페이지 내비게이션 시 스크립트를 실행할 수 있게 하는 메서드가 있습니다.
그 메서드를 사용해야 할까요? 아니요. 그 메서드는 분석(analytics)과 같은 용도에 매우 유용합니다. <audio> 태그만 처리하는 스크립트를 모든 페이지에서 실행하는 것은 의미가 없습니다. 특히 해당 페이지에 그런 태그가 전혀 없는 경우라면요.
그렇다면 다음은 무엇일까요? 좋은 소식은 여러분이 이미 답을 찾았다는 것입니다. decorateCookedElement가 여기에서 사용해야 할 올바른 메서드입니다.
이 메서드는 … 기다려 보세요 … 게시물을 꾸밀(decorate) 방법을 제공합니다
Discourse는 여러분이 추가하는 모든 데코레이터가 모든 게시물에 적용된다고 보장합니다.
좋습니다, 여러분은 게시물 데코레이터에서 스크립트를 로드하고 있으므로 스크립트가 추가되고 작동해야 합니다. 그런데 왜 후속 내비게이션 시에는 작동하지 않는 걸까요?
이것을 이해하려면 loadScript()가 어떻게 작동하는지 알아야 합니다. 여러분의 코드는 이미 스크립트를 로드하기 전에 유효한 대상 요소가 있는지 확인하므로
하지만 유효한 요소가 있는 20~30개의 게시물이 연속으로 있는 상황을 상상해 보세요. 스크립트를 20~30번 로드하는 것이 합리적일까요? 당연히 아닙니다.
loadScript()는 스크립트가 이미 로드되었는지 감지할 만큼 충분히 지능적입니다. 중복을 로드하지 않으며, 다운로드가 이미 완료된 스크립트를 다시 로드하지도 않습니다. 여기에서 그 내용을 볼 수 있습니다.
위의 fullUrl은 loadScript()를 호출할 때 전달하는 URL이며, 여러분의 예제와 같습니다.
이제 이 점을 알았으니, 왜 후속 내비게이션 시 작동하지 않는지 어느 정도 이해할 수 있습니다.
topic-a 방문 > 오디오 요소 있음 > loadScript()가 스크립트 로드 > 스크립트가 멋진 "자동 초기화" 수행 > 스크립트가 여러분의 요소에서 초기화됨 > 사용자 정의 오디오 요소 획득
그리고...
topic-b 방문 > 오디오 요소 있음 > loadScript()가 스크립트가 이미 로드된 것을 감지 > 멋진 "자동 초기화" 없음 > 기본 오디오 요소 획득 > 슬픔이 찾아옴
그렇다면 어떻게 수정할까요? 바로 앞서 언급한 #2번 방법의 용도입니다.
요소에 CSS 클래스와 몇 가지 속성을 추가하면 자동으로 처리해 줍니다.
수동으로 초기화합니다
자, 그렇게 해보겠습니다. 이미 공유하신 페이지에 문서화되어 있습니다. 대상 요소에서 이렇게 호출해야 합니다.
// 플레이어 ID로 문자열(예: `player`)을 사용할 수 있으며,
// 또는 `document.querySelector()`로 어떤 선택자든 사용할 수 있습니다
var player = new MediaElementPlayer("player", {
// ... 옵션
});
여러분의 코드는 이미 각 개별 오디오 요소를 별도로 처리하므로 이 부분을 수정하기만 하면 됩니다.
이 부분은 잠시 보류하고 데코레이터의 나머지 부분을 살펴보겠습니다. 지금까지의 모습은 다음과 같습니다.
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 used to be here
},
{ id: "mediaelement-js", onlyStream: true }
);
```\n
알아보시면, 두 개의 다른 스크립트에 대해 `loadScript()`를 호출하고 있습니다. 이것이 의도적인지 확신은 없지만, 그중 하나만 있으면 됩니다. 이를 전체 번들(full bundle)과 경량 버전(lightweight)으로 생각하세요. 여러분은 사용자 정의 오디오 플레이어가 필요합니다. 따라서 전체 번들이 필요합니다. 다른 하나는 제거해 보겠습니다.
```diff
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 used to be here
},
{ 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 used to be here
},
{ id: "mediaelement-js", onlyStream: true }
);
그 다음, 이를 맨 위로 이동하고 길이가 falsey(길이가 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 used to be here
},
{ 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 used to be here
},
{ 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 used to be here
},
{ id: "mediaelement-js", onlyStream: true }
);
이제 앞서의 forEach 루프로 돌아가서 바로 여기에 추가할 수 있으며, 스크립트가 사용할 수 있음을 확신할 수 있습니다.
그리고 스크립트의 CSS를 로드하고 스크립트가 요소를 교체할 때의 떨림(jitter)을 방지하기 위해 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;
}
}
이제 유효한 요소가 있는 모든 게시물에서 사용자 정의 플레이어를 볼 수 있어야 합니다.
이 부분을 마무리했으니, 선택하신 라이브러리가 꽤 오래되었음을 유의해야 합니다. 오래된 브라우저를 위해 트랜스파일되어 있으며, 이제 표준이 된 많은 기능을 폴리필(polyfill)하려고 시도합니다.
사용하려는 이유가 명확하다면 괜찮습니다. 하지만 플레이어의 외관을 사용자 정의하기 위해만 사용하는 것이라면, 사용을 피하는 것을 권장합니다. 확인은 하지 않았지만, 훨씬 가볍고 현대적인 대안이 있을 가능성이 높습니다.
이 모든 것의 가장 좋은 점은, 위에서 설명한 구현 방식이 변하지 않는다는 것입니다. 어떤 요소를 대상으로 하든, 어떤 스크립트를 사용하든 동일한 패턴이 적용됩니다. 변하는 것은 사용자 정의 스크립트를 초기화하는 부분뿐입니다. 모든 양질의 라이브러리는 이를 안내하는 좋은 문서를 갖추고 있습니다. 그런 다음, 그것을 위의 패턴에 넣기만 하면 됩니다.
이런 순간에는 메타가 Discourse Reactions 을 사용했으면 좋겠다. 현재 느끼는 사랑과 순수한 감사함을 표현하기에는 하트가 부족하기 때문이다.
누군가 최소한 팁이라도 주기를 바랐는데, 당신은 시간을 내어 내가 어떻게 여기까지 왔는지 매우 상세하게 설명해 주었고, 어떻게 작동하게 만드는지 하나하나 알려주며 그 과정에서 많은 것을 가르쳐 주셨다(지터에 대한 작은 세부 사항까지!)
나는 디스코스의 구조가 이러한 행동을 장려하는 데 어떻게 도움이 되는지 배우고 있다. 한 번 잘 답변하면 다른 사람들이 그걸 볼 수 있고, 다시 답변할 필요가 없기 때문이다. 이는 이 플랫폼으로 커뮤니티를 계속 구축해 나가도록 나를 격려한다. 그러나 당신이 왜 이렇게까지 자세히 작성해 주었는지를 완전히 설명해 주지는 않는다. 당신이 그렇게 기꺼이 도와준다는 사실 자체가 이 플랫폼을 더 많이 사용하도록 나를 격려해 줄 수 있다.
감사합니다.
미디어엘리먼트(Mediaelement)에 관해서 말하자면, 네, 오래된 라이브러리이지만 내가 가진 워드프레스 사이트와 잘 어울린다. 워드프레스 쪽에서 많이 커스터마이징하여 사용자에게 익숙한 느낌을 제공하려 노력하고 있다(그리고 지금은 또 다른 라이브러리를 배우려 하지 않으려 하기 때문이다 )
추가 질문이자 어쩌면 멍청한 질문일 수 있습니다: Mediaelement가 플러그인 스크립트를 허용하므로, 이제 여러 스크립트를 로드하려고 하고 있습니다. Promise를 반환하기 전에 모든 스크립트가 로드되었는지 확인하고 싶습니다.
스크립트 소스의 상수들을 순회하면서 Promise 배열을 만들고, 플레이어 초기화를 위해 Promise.all()을 사용하려고 시도해 보았지만, 이렇게 하면 mejs를 찾을 수 없다는 오류가 발생합니다. mejs는 mediaelement-and-player 내부의 다양한 함수를 호출하는 네임스페이스나 같은 것이라 생각합니다.
loadScript()는 로드하는 스크립트에 async 속성을 설정하지 않습니다. 따라서 기본적으로 async="true"가 적용되어 로드 순서가 어긋나게 됩니다. 이는 브라우저의 특성입니다. JS로 로드하는 스크립트에 대해서는 async="false"로 강제해야 합니다.
플러그인은 크기가 작아서 메인 번들보다 빠르게 로드되지만, async 상태에서는 더 이상 로드 순서를 따르지 않습니다. 즉, 메인 번들이 로드되고 실행된 후에 실행되어야 합니다.
아마도 loadScript가 코어 어디에도 중첩되어 있지 않아서(제가 아는 한) 이 문제가 눈에 띄지 않았을 것입니다. 일반적으로 함께 작동해야 하는 파일들은 번들링합니다. 따라서 다른 질문에 대한 답변도 드릴게요. 아니요, 그런 일을 처리하는 Discourse 함수는 없습니다.
다른 스니펫도 정상적으로 작동할 것입니다. 가독성을 조금 높이기 위해 중첩 대신 체이닝으로 작성해 보는 것을 추천합니다.
// This goes outside the decorator
const PLUGINS = {
speed: "https://example.com/foo.js",
skipBack: "https://example.com/bar.js",
jumpForward: "https://example.com/baz.js"
};
// Then do your work inside the decorator
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);
});
});