diff --git a/slideshowpure.js b/slideshowpure.js index f3395a7..d99818a 100644 --- a/slideshowpure.js +++ b/slideshowpure.js @@ -22,6 +22,20 @@ const CONFIG = { fadeTransitionDuration: 500, slideAnimationEnabled: true, enableTrailers: true, + // Name fragments identifying alternate cuts of a trailer: accessibility + // variants and social crops. Matched case insensitively against the + // RemoteTrailers entry name. These are demoted within their tier, so an + // alternate cut loses to an ordinary entry of the same tier but still + // beats lower tier promo clips, and an item whose only trailer is an + // alternate cut still plays something. + trailerAlternateCutTerms: [ + "sign language", + "asl trailer", + "audio description", + "audio described", + "described audio", + "vertical", + ], youtubeApiLoadTimeoutMs: 8000, }; @@ -1198,6 +1212,73 @@ const SlideCreator = { } }, + /** + * Picks the YouTube video id of the most appropriate trailer for an item. + * + * RemoteTrailers arrives in metadata provider order, which is not preference + * order. The first entry is frequently a promo clip, a teaser or an + * accessibility variant rather than the main trailer, and it is not + * guaranteed to be a YouTube link at all. Entries are ranked by name, with + * provider order breaking ties, and any entry that does not yield a video id + * is skipped instead of losing the trailer for that slide. Alternate cuts + * are demoted within their tier: they lose to an ordinary entry of the same + * tier but still beat lower tier promo clips. + * + * @param {Array} remoteTrailers - RemoteTrailers array from the item + * @returns {string|null} YouTube video id, or null if no entry is usable + */ + selectTrailerVideoId(remoteTrailers) { + if (!Array.isArray(remoteTrailers) || remoteTrailers.length === 0) { + return null; + } + + const rankName = (name) => { + const text = (name || "").toLowerCase(); + const isAlternateCut = CONFIG.trailerAlternateCutTerms.some((term) => + text.includes(term), + ); + + let rank; + if (text.includes("official trailer")) rank = 5; + else if (text.includes("final trailer") || text.includes("main trailer")) + rank = 4; + else if (text.includes("trailer")) rank = 3; + else if (text.includes("teaser")) rank = 2; + else rank = 1; + + return isAlternateCut ? rank - 0.5 : rank; + }; + + let best = null; + + for (const trailer of remoteTrailers) { + let videoId = null; + try { + const urlObj = new URL(trailer.Url); + const host = urlObj.hostname.replace(/^www\./, ""); + if (host === "youtu.be") { + videoId = urlObj.pathname.split("/")[1] || null; + } else if ( + (host === "youtube.com" || host === "m.youtube.com") && + urlObj.pathname.startsWith("/embed/") + ) { + videoId = urlObj.pathname.split("/")[2] || null; + } else { + videoId = urlObj.searchParams.get("v"); + } + } catch (e) {} + + if (!videoId) continue; + + const rank = rankName(trailer.Name); + if (!best || rank > best.rank) { + best = { videoId, rank }; + } + } + + return best ? best.videoId : null; + }, + /** * Creates a slide element for an item * @param {Object} item - Item data @@ -1221,15 +1302,8 @@ const SlideCreator = { let videoId = null; let trailerContainer = null; - if ( - CONFIG.enableTrailers && - item.RemoteTrailers && - item.RemoteTrailers.length > 0 - ) { - try { - const urlObj = new URL(item.RemoteTrailers[0].Url); - videoId = urlObj.searchParams.get("v"); - } catch (e) {} + if (CONFIG.enableTrailers) { + videoId = this.selectTrailerVideoId(item.RemoteTrailers); if (videoId) { trailerContainer = SlideUtils.createElement("div", {