701 lines
31 KiB
JavaScript
701 lines
31 KiB
JavaScript
// ==UserScript==
|
||
// @name embyLaunchPotplayer
|
||
// @name:en embyLaunchPotplayer
|
||
// @name:zh embyLaunchPotplayer
|
||
// @name:zh-CN embyLaunchPotplayer
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 1.1.22
|
||
// @description emby/jellfin launch extetnal player
|
||
// @description:zh-cn emby/jellfin 调用外部播放器
|
||
// @description:en emby/jellfin to external player
|
||
// @license MIT
|
||
// @author @bpking
|
||
// @github https://github.com/bpking1/embyExternalUrl
|
||
// @match *://*/web/index.html
|
||
// @match *://*/web/
|
||
// ==/UserScript==
|
||
|
||
(function () {
|
||
'use strict';
|
||
const iconConfig = {
|
||
// 图标来源,以下三选一,注释为只留一个,3 的优先级最高
|
||
// 1.add icons from jsdelivr, network
|
||
baseUrl: "https://emby-external-url.7o7o.cc/embyWebAddExternalUrl/icons",
|
||
// baseUrl: "https://fastly.jsdelivr.net/gh/bpking1/embyExternalUrl@main/embyWebAddExternalUrl/icons",
|
||
// 2.server local icons, same as /emby-server/system/dashboard-ui/icons
|
||
// baseUrl: "icons",
|
||
// 3.add icons from Base64, script inner, this script size 22.5KB to 74KB,
|
||
// 自行复制 ./iconsExt.js 内容到此脚本的 getIconsExt 中
|
||
// 移除最后几个冗余的自定义开关
|
||
removeCustomBtns: false,
|
||
};
|
||
// 启用后将修改直接串流链接为真实文件名,方便第三方播放器友好显示和匹配,
|
||
// 默认不启用,强依赖 nginx-emby2Alist location two rewrite,如发现原始链接播放失败,请关闭此选项
|
||
const useRealFileName = false;
|
||
// 以下为内部使用变量,请勿更改
|
||
let isEmby = "";
|
||
const mark = "embyLaunchPotplayer";
|
||
const playBtnsWrapperId = "ExternalPlayersBtns";
|
||
const lsKeys = {
|
||
iconOnly: `${mark}-iconOnly`,
|
||
hideByOS: `${mark}-hideByOS`,
|
||
notCurrentPot: `${mark}-notCurrentPot`,
|
||
strmDirect: `${mark}-strmDirect`,
|
||
};
|
||
const OS = {
|
||
isAndroid: () => /android/i.test(navigator.userAgent),
|
||
isIOS: () => /iPad|iPhone|iPod/i.test(navigator.userAgent),
|
||
isMacOS: () => /Macintosh|MacIntel/i.test(navigator.userAgent),
|
||
isApple: () => OS.isMacOS() || OS.isIOS(),
|
||
isWindows: () => /compatible|Windows/i.test(navigator.userAgent),
|
||
isMobile: () => OS.isAndroid() || OS.isIOS(),
|
||
isUbuntu: () => /Ubuntu/i.test(navigator.userAgent),
|
||
// isAndroidEmbyNoisyX: () => OS.isAndroid() && ApiClient.appVersion().includes('-'),
|
||
// isEmbyNoisyX: () => ApiClient.appVersion().includes('-'),
|
||
isOthers: () => Object.entries(OS).filter(([key, val]) => key !== 'isOthers').every(([key, val]) => !val()),
|
||
};
|
||
const playBtns = [
|
||
{ id: "embyPot", title: "Potplayer", iconId: "icon-PotPlayer"
|
||
, onClick: embyPot, osCheck: [OS.isWindows], },
|
||
{ id: "embyVlc", title: "VLC", iconId: "icon-VLC", onClick: embyVlc, },
|
||
{ id: "embyIINA", title: "IINA", iconId: "icon-IINA"
|
||
, onClick: embyIINA, osCheck: [OS.isMacOS], },
|
||
{ id: "embyNPlayer", title: "NPlayer", iconId: "icon-NPlayer", onClick: embyNPlayer, },
|
||
{ id: "embyMX", title: "MXPlayer", iconId: "icon-MXPlayer"
|
||
, onClick: embyMX, osCheck: [OS.isAndroid], },
|
||
{ id: "embyMXPro", title: "MXPlayerPro", iconId: "icon-MXPlayerPro"
|
||
, onClick: embyMXPro, osCheck: [OS.isAndroid], },
|
||
{ id: "embyInfuse", title: "Infuse", iconId: "icon-infuse"
|
||
, onClick: embyInfuse, osCheck: [OS.isApple], },
|
||
{ id: "embyStellarPlayer", title: "恒星播放器", iconId: "icon-StellarPlayer"
|
||
, onClick: embyStellarPlayer, osCheck: [OS.isWindows, OS.isMacOS, OS.isAndroid], },
|
||
{ id: "embyMPV", title: "MPV", iconId: "icon-MPV", onClick: embyMPV, },
|
||
{ id: "embyDDPlay", title: "弹弹Play", iconId: "icon-DDPlay"
|
||
, onClick: embyDDPlay, osCheck: [OS.isWindows, OS.isAndroid], },
|
||
{ id: "embyFileball", title: "Fileball", iconId: "icon-Fileball"
|
||
, onClick: embyFileball, osCheck: [OS.isApple], },
|
||
{ id: "embyOmniPlayer", title: "OmniPlayer", iconId: "icon-OmniPlayer"
|
||
, onClick: embyOmniPlayer, osCheck: [OS.isMacOS], },
|
||
{ id: "embyFigPlayer", title: "FigPlayer", iconId: "icon-FigPlayer"
|
||
, onClick: embyFigPlayer, osCheck: [OS.isMacOS], },
|
||
{ id: "embySenPlayer", title: "SenPlayer", iconId: "icon-SenPlayer"
|
||
, onClick: embySenPlayer, osCheck: [OS.isIOS], },
|
||
{ id: "embyCopyUrl", title: "复制串流地址", iconId: "icon-Copy", onClick: embyCopyUrl, },
|
||
];
|
||
// Jellyfin Icons: https://marella.github.io/material-icons/demo
|
||
// Emby Icons: https://fonts.google.com/icons
|
||
const customBtns = [
|
||
{ id: "hideByOS", title: "异构播放器", iconName: "more", onClick: hideByOSHandler, },
|
||
{ id: "iconOnly", title: "显示模式", iconName: "open_in_full", onClick: iconOnlyHandler, },
|
||
{ id: "notCurrentPot", title: "多开Potplayer", iconName: "window", onClick: notCurrentPotHandler, },
|
||
{ id: "strmDirect", title: "STRM直通", desc: "AList注意关sign,否则不要开启此选项,任然由服务端处理sign"
|
||
, iconName: "link", onClick: strmDirectHandler,
|
||
},
|
||
];
|
||
if (!iconConfig.removeCustomBtns) {
|
||
playBtns.push(...customBtns);
|
||
}
|
||
const fileNameReg = /.*[\\/]|(\?.*)?$/g;
|
||
const selectors = {
|
||
// 详情页评分,上映日期信息栏
|
||
embyMediaInfoDiv: "div[is='emby-scroller']:not(.hide) .mediaInfo:not(.hide)",
|
||
jellfinMediaInfoDiv: ".itemMiscInfo-primary:not(.hide)",
|
||
// 电视直播详情页创建录制按钮
|
||
embyBtnManualRecording: "div[is='emby-scroller']:not(.hide) .btnManualRecording:not(.hide)",
|
||
// 电视直播详情页停止录制按钮
|
||
jellfinBtnCancelTimer: ".btnCancelTimer:not(.hide)",
|
||
// 详情页播放收藏那排按钮
|
||
embyMainDetailButtons: "div[is='emby-scroller']:not(.hide) .mainDetailButtons",
|
||
jellfinMainDetailButtons: "div.itemDetailPage:not(.hide) div.detailPagePrimaryContainer",
|
||
// 详情页字幕选择下拉框
|
||
selectSubtitles: "div[is='emby-scroller']:not(.hide) select.selectSubtitles",
|
||
// 详情页多版本选择下拉框
|
||
selectSource: "div[is='emby-scroller']:not(.hide) select.selectSource:not([disabled])",
|
||
};
|
||
|
||
function init() {
|
||
let playBtnsWrapper = document.getElementById(playBtnsWrapperId);
|
||
if (playBtnsWrapper) {
|
||
playBtnsWrapper.remove();
|
||
}
|
||
let mainDetailButtons = document.querySelector(selectors.embyMainDetailButtons);
|
||
function generateButtonHTML({ id, title, desc, iconId, iconName }) {
|
||
// jellyfin icon class: material-icons
|
||
return `
|
||
<button
|
||
id="${id}"
|
||
type="button"
|
||
class="detailButton emby-button emby-button-backdropfilter raised-backdropfilter detailButton-primary"
|
||
title="${desc ? desc : title}"
|
||
>
|
||
<div class="detailButton-content">
|
||
<i class="md-icon detailButton-icon button-icon button-icon-left material-icons" id="${iconId}">
|
||
${iconName ? iconName : ' '}
|
||
</i>
|
||
<span class="button-text">${title}</span>
|
||
</div>
|
||
</button>
|
||
`;
|
||
}
|
||
let buttonHtml = `<div id="${playBtnsWrapperId}" class="detailButtons flex align-items-flex-start flex-wrap-wrap detail-lineItem">`;
|
||
playBtns.forEach(btn => {
|
||
buttonHtml += generateButtonHTML(btn);
|
||
});
|
||
buttonHtml += `</div>`;
|
||
|
||
if (!isEmby) {
|
||
// jellfin
|
||
mainDetailButtons = document.querySelector(selectors.jellfinMainDetailButtons);
|
||
}
|
||
|
||
mainDetailButtons.insertAdjacentHTML("afterend", buttonHtml);
|
||
|
||
if (!isEmby) {
|
||
// jellfin add class, detailPagePrimaryContainer、button-flat
|
||
let playBtnsWrapper = document.getElementById("ExternalPlayersBtns");
|
||
// style to cover .layout-mobile
|
||
playBtnsWrapper.style.display = "flex";
|
||
// playBtnsWrapper.style["justifyContent"] = "center";
|
||
playBtnsWrapper.classList.add("detailPagePrimaryContainer");
|
||
let btns = playBtnsWrapper.getElementsByTagName("button");
|
||
for (let i = 0; i < btns.length; i++) {
|
||
btns[i].classList.add("button-flat");
|
||
}
|
||
}
|
||
|
||
// add event
|
||
playBtns.forEach(btn => {
|
||
const btnEle = document.querySelector(`#${btn.id}`);
|
||
if (btnEle) {
|
||
btnEle.onclick = btn.onClick;
|
||
}
|
||
});
|
||
|
||
const iconBaseUrl = iconConfig.baseUrl;
|
||
const icons = [
|
||
// if url exists, use url property, if id diff icon name, use name property
|
||
{ id: "icon-PotPlayer", name: "icon-PotPlayer.webp", fontSize: "1.4em" },
|
||
{ id: "icon-VLC", fontSize: "1.3em" },
|
||
{ id: "icon-IINA", fontSize: "1.4em" },
|
||
{ id: "icon-NPlayer", fontSize: "1.3em" },
|
||
{ id: "icon-MXPlayer", fontSize: "1.4em" },
|
||
{ id: "icon-MXPlayerPro", fontSize: "1.4em" },
|
||
{ id: "icon-infuse", fontSize: "1.4em" },
|
||
{ id: "icon-StellarPlayer", fontSize: "1.4em" },
|
||
{ id: "icon-MPV", fontSize: "1.4em" },
|
||
{ id: "icon-DDPlay", fontSize: "1.4em" },
|
||
{ id: "icon-Fileball", fontSize: "1.4em" },
|
||
{ id: "icon-SenPlayer", fontSize: "1.4em" },
|
||
{ id: "icon-OmniPlayer", fontSize: "1.4em" },
|
||
{ id: "icon-FigPlayer", fontSize: "1.4em" },
|
||
{ id: "icon-Copy", fontSize: "1.4em" },
|
||
];
|
||
const iconsExt = getIconsExt();
|
||
icons.map((icon, index) => {
|
||
const element = document.querySelector(`#${icon.id}`);
|
||
if (element) {
|
||
// if url exists, use url property, if id diff icon name, use name property
|
||
icon.url = typeof iconsExt !== 'undefined' && iconsExt && iconsExt[index] ? iconsExt[index].url : undefined;
|
||
const url = icon.url || `${iconBaseUrl}/${icon.name || `${icon.id}.webp`}`;
|
||
element.style.cssText += `
|
||
background-image: url(${url});
|
||
background-repeat: no-repeat;
|
||
background-size: 100% 100%;
|
||
font-size: ${icon.fontSize};
|
||
`;
|
||
}
|
||
});
|
||
if (!iconConfig.removeCustomBtns) {
|
||
hideByOSHandler();
|
||
iconOnlyHandler();
|
||
notCurrentPotHandler();
|
||
strmDirectHandler();
|
||
}
|
||
}
|
||
|
||
// copy from ./iconsExt,如果更改了以下内容,请同步更改 ./iconsExt.js
|
||
function getIconsExt() {
|
||
// base64 data total size 72.5 KB from embyWebAddExternalUrl/icons/min, sync modify
|
||
const iconsExt = [];
|
||
return iconsExt;
|
||
}
|
||
|
||
function showFlag() {
|
||
let mediaInfoDiv = document.querySelector(selectors.embyMediaInfoDiv);
|
||
let btnManualRecording = document.querySelector(selectors.embyBtnManualRecording);
|
||
if (!isEmby) {
|
||
mediaInfoDiv = document.querySelector(selectors.jellfinMediaInfoDiv);
|
||
btnManualRecording = document.querySelector(selectors.jellfinBtnCancelTimer);
|
||
}
|
||
return !!mediaInfoDiv || !!btnManualRecording;
|
||
}
|
||
|
||
async function getItemInfo() {
|
||
let userId = ApiClient._serverInfo.UserId;
|
||
let itemId = /\?id=([A-Za-z0-9]+)/.exec(window.location.hash)[1];
|
||
let response = await ApiClient.getItem(userId, itemId);
|
||
// 继续播放当前剧集的下一集
|
||
if (response.Type == "Series") {
|
||
let seriesNextUpItems = await ApiClient.getNextUpEpisodes({ SeriesId: itemId, UserId: userId });
|
||
if (seriesNextUpItems.Items.length > 0) {
|
||
console.log("nextUpItemId: " + seriesNextUpItems.Items[0].Id);
|
||
return await ApiClient.getItem(userId, seriesNextUpItems.Items[0].Id);
|
||
}
|
||
}
|
||
// 播放当前季season的第一集
|
||
if (response.Type == "Season") {
|
||
let seasonItems = await ApiClient.getItems(userId, { parentId: itemId });
|
||
console.log("seasonItemId: " + seasonItems.Items[0].Id);
|
||
return await ApiClient.getItem(userId, seasonItems.Items[0].Id);
|
||
}
|
||
// 播放当前集或电影
|
||
if (response.MediaSources?.length > 0) {
|
||
console.log("itemId: " + itemId);
|
||
return response;
|
||
}
|
||
// 默认播放第一个,集/播放列表第一个媒体
|
||
let firstItems = await ApiClient.getItems(userId, { parentId: itemId, Recursive: true, IsFolder: false, Limit: 1 });
|
||
console.log("firstItemId: " + firstItems.Items[0].Id);
|
||
return await ApiClient.getItem(userId, firstItems.Items[0].Id);
|
||
}
|
||
|
||
function getSeek(position) {
|
||
let ticks = position * 10000;
|
||
let parts = []
|
||
, hours = ticks / 36e9;
|
||
(hours = Math.floor(hours)) && parts.push(hours);
|
||
let minutes = (ticks -= 36e9 * hours) / 6e8;
|
||
ticks -= 6e8 * (minutes = Math.floor(minutes)),
|
||
minutes < 10 && hours && (minutes = "0" + minutes),
|
||
parts.push(minutes);
|
||
let seconds = ticks / 1e7;
|
||
return (seconds = Math.floor(seconds)) < 10 && (seconds = "0" + seconds),
|
||
parts.push(seconds),
|
||
parts.join(":")
|
||
}
|
||
|
||
function getSubPath(mediaSource) {
|
||
let selectSubtitles = document.querySelector(selectors.selectSubtitles);
|
||
let subTitlePath = '';
|
||
//返回选中的外挂字幕
|
||
if (selectSubtitles && selectSubtitles.value > 0) {
|
||
let SubIndex = mediaSource.MediaStreams.findIndex(m => m.Index == selectSubtitles.value && m.IsExternal);
|
||
if (SubIndex > -1) {
|
||
let subtitleCodec = mediaSource.MediaStreams[SubIndex].Codec;
|
||
subTitlePath = `/${mediaSource.Id}/Subtitles/${selectSubtitles.value}/Stream.${subtitleCodec}`;
|
||
}
|
||
}
|
||
else {
|
||
//默认尝试返回第一个外挂中文字幕
|
||
let chiSubIndex = mediaSource.MediaStreams.findIndex(m => m.Language == "chi" && m.IsExternal);
|
||
if (chiSubIndex > -1) {
|
||
let subtitleCodec = mediaSource.MediaStreams[chiSubIndex].Codec;
|
||
subTitlePath = `/${mediaSource.Id}/Subtitles/${chiSubIndex}/Stream.${subtitleCodec}`;
|
||
} else {
|
||
//尝试返回第一个外挂字幕
|
||
let externalSubIndex = mediaSource.MediaStreams.findIndex(m => m.IsExternal);
|
||
if (externalSubIndex > -1) {
|
||
let subtitleCodec = mediaSource.MediaStreams[externalSubIndex].Codec;
|
||
subTitlePath = `/${mediaSource.Id}/Subtitles/${externalSubIndex}/Stream.${subtitleCodec}`;
|
||
}
|
||
}
|
||
|
||
}
|
||
return subTitlePath;
|
||
}
|
||
|
||
async function getEmbyMediaInfo() {
|
||
let itemInfo = await getItemInfo();
|
||
let mediaSourceId = itemInfo.MediaSources[0].Id;
|
||
let selectSource = document.querySelector(selectors.selectSource);
|
||
if (selectSource && selectSource.value.length > 0) {
|
||
mediaSourceId = selectSource.value;
|
||
}
|
||
// let selectAudio = document.querySelector("div[is='emby-scroller']:not(.hide) select.selectAudio:not([disabled])");
|
||
const accessToken = ApiClient.accessToken();
|
||
let mediaSource = itemInfo.MediaSources.find(m => m.Id == mediaSourceId);
|
||
let uri = isEmby ? "/emby/videos" : "/Items";
|
||
let baseUrl = `${ApiClient._serverAddress}${uri}/${itemInfo.Id}`;
|
||
let subPath = getSubPath(mediaSource);
|
||
let subUrl = subPath.length > 0 ? `${baseUrl}${subPath}?api_key=${accessToken}` : "";
|
||
let streamUrl = `${baseUrl}/`;
|
||
if (mediaSource.Path.startsWith("http") && localStorage.getItem(lsKeys.strmDirect) === "1") {
|
||
streamUrl = decodeURIComponent(mediaSource.Path);
|
||
} else {
|
||
let fileName = mediaSource.IsInfiniteStream ? `master.m3u8` : decodeURIComponent(mediaSource.Path.replace(fileNameReg, ""));
|
||
if (isEmby) {
|
||
if (mediaSource.IsInfiniteStream) {
|
||
streamUrl += useRealFileName && mediaSource.Name ? `${mediaSource.Name}.m3u8` : fileName;
|
||
} else {
|
||
// origin link: /emby/videos/401929/stream.xxx?xxx
|
||
// modify link: /emby/videos/401929/stream/xxx.xxx?xxx
|
||
// this is not important, hit "/emby/videos/401929/" path level still worked
|
||
streamUrl += useRealFileName ? `stream/${fileName}` : `stream.${mediaSource.Container}`;
|
||
}
|
||
} else {
|
||
streamUrl += `Download`;
|
||
streamUrl += useRealFileName ? `/${fileName}` : "";
|
||
}
|
||
streamUrl += `?api_key=${accessToken}&Static=true&MediaSourceId=${mediaSourceId}&DeviceId=${ApiClient._deviceId}`;
|
||
}
|
||
let position = parseInt(itemInfo.UserData.PlaybackPositionTicks / 10000);
|
||
let intent = await getIntent(mediaSource, position);
|
||
console.log(streamUrl, subUrl, intent);
|
||
return {
|
||
streamUrl: streamUrl,
|
||
subUrl: subUrl,
|
||
intent: intent,
|
||
}
|
||
}
|
||
|
||
async function getIntent(mediaSource, position) {
|
||
// 直播节目查询items接口没有path
|
||
let title = mediaSource.IsInfiniteStream
|
||
? mediaSource.Name
|
||
: decodeURIComponent(mediaSource.Path.replace(fileNameReg, ""));
|
||
let externalSubs = mediaSource.MediaStreams.filter(m => m.IsExternal == true);
|
||
let subs = ''; // 要求是android.net.uri[] ?
|
||
let subs_name = '';
|
||
let subs_filename = '';
|
||
let subs_enable = '';
|
||
if (externalSubs) {
|
||
subs_name = externalSubs.map(s => s.DisplayTitle);
|
||
subs_filename = externalSubs.map(s => s.Path.split('/').pop());
|
||
}
|
||
return {
|
||
title: title,
|
||
position: position,
|
||
subs: subs,
|
||
subs_name: subs_name,
|
||
subs_filename: subs_filename,
|
||
subs_enable: subs_enable,
|
||
path: mediaSource.Path,
|
||
};
|
||
}
|
||
|
||
// URL with "intent" scheme only support
|
||
// String => 'S'
|
||
// Boolean =>'B'
|
||
// Byte => 'b'
|
||
// Character => 'c'
|
||
// Double => 'd'
|
||
// Float => 'f'
|
||
// Integer => 'i'
|
||
// Long => 'l'
|
||
// Short => 's'
|
||
|
||
async function embyPot() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
const intent = mediaInfo.intent;
|
||
const notCurrentPotArg = localStorage.getItem(lsKeys.notCurrentPot) === "1" ? "" : "/current";
|
||
let potUrl = `potplayer://${encodeURI(mediaInfo.streamUrl)} /sub=${encodeURI(mediaInfo.subUrl)} ${notCurrentPotArg} /seek=${getSeek(intent.position)} /title="${intent.title}"`;
|
||
await writeClipboard(potUrl);
|
||
console.log("成功写入剪切板真实深度链接: ", potUrl);
|
||
// 测试出无空格也行,potplayer 对于 DeepLink 会自动转换为命令行参数,全量参数: PotPlayer 关于 => 命令行选项
|
||
potUrl = `potplayer://${notCurrentPotArg}/clipboard`;
|
||
window.open(potUrl, "_self");
|
||
}
|
||
|
||
// async function embyPot() {
|
||
// let mediaInfo = await getEmbyMediaInfo();
|
||
// let intent = mediaInfo.intent;
|
||
// let potUrl = `potplayer://${encodeURI(mediaInfo.streamUrl)} /sub=${encodeURI(mediaInfo.subUrl)} /current /seek=${getSeek(intent.position)}`;
|
||
// potUrl += useRealFileName ? '' : ` /title="${intent.title}"`;
|
||
// console.log(potUrl);
|
||
// window.open(potUrl, "_self");
|
||
// }
|
||
|
||
// https://wiki.videolan.org/Android_Player_Intents/
|
||
async function embyVlc() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
let intent = mediaInfo.intent;
|
||
// android subtitles: https://code.videolan.org/videolan/vlc-android/-/issues/1903
|
||
let vlcUrl = `intent:${encodeURI(mediaInfo.streamUrl)}#Intent;package=org.videolan.vlc;type=video/*;S.subtitles_location=${encodeURI(mediaInfo.subUrl)};S.title=${encodeURI(intent.title)};i.position=${intent.position};end`;
|
||
if (OS.isWindows() || OS.isMacOS()) {
|
||
// 桌面端需要额外设置,参考这个项目:
|
||
// new: https://github.com/northsea4/vlc-protocol
|
||
// old: https://github.com/stefansundin/vlc-protocol
|
||
vlcUrl = `vlc://${encodeURI(mediaInfo.streamUrl)}`;
|
||
}
|
||
if (OS.isIOS()) {
|
||
// https://wiki.videolan.org/Documentation:IOS/#x-callback-url
|
||
// https://code.videolan.org/videolan/vlc-ios/-/commit/55e27ed69e2fce7d87c47c9342f8889fda356aa9
|
||
vlcUrl = `vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(mediaInfo.streamUrl)}&sub=${encodeURIComponent(mediaInfo.subUrl)}`;
|
||
}
|
||
console.log(vlcUrl);
|
||
window.open(vlcUrl, "_self");
|
||
}
|
||
|
||
// MPV
|
||
async function embyMPV() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
// 桌面端需要额外设置,参考这个项目:
|
||
// new: https://github.com/northsea4/mpvplay-protocol
|
||
// old: https://github.com/akiirui/mpv-handler
|
||
let streamUrl64 = btoa(String.fromCharCode.apply(null, new Uint8Array(new TextEncoder().encode(mediaInfo.streamUrl))))
|
||
.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
|
||
let MPVUrl = `mpv://play/${streamUrl64}`;
|
||
if (mediaInfo.subUrl.length > 0) {
|
||
let subUrl64 = btoa(mediaInfo.subUrl).replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
|
||
MPVUrl = `mpv://play/${streamUrl64}/?subfile=${subUrl64}`;
|
||
}
|
||
|
||
if (OS.isIOS() || OS.isAndroid()) {
|
||
MPVUrl = `mpv://${encodeURI(mediaInfo.streamUrl)}`;
|
||
}
|
||
if (OS.isMacOS()) {
|
||
MPVUrl = `mpvplay://${encodeURI(mediaInfo.streamUrl)}`;
|
||
}
|
||
|
||
console.log(MPVUrl);
|
||
window.open(MPVUrl, "_self");
|
||
}
|
||
|
||
// https://github.com/iina/iina/issues/1991
|
||
async function embyIINA() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
let iinaUrl = `iina://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}&new_window=1`;
|
||
console.log(`iinaUrl= ${iinaUrl}`);
|
||
window.open(iinaUrl, "_self");
|
||
}
|
||
|
||
// https://sites.google.com/site/mxvpen/api
|
||
// https://mx.j2inter.com/api
|
||
// https://support.mxplayer.in/support/solutions/folders/43000574903
|
||
async function embyMX() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
const intent = mediaInfo.intent;
|
||
// mxPlayer free
|
||
const packageName = "com.mxtech.videoplayer.ad";
|
||
const url = `intent:${encodeURI(mediaInfo.streamUrl)}#Intent;package=${packageName};S.title=${encodeURI(intent.title)};i.position=${intent.position};end`;
|
||
console.log(url);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
async function embyMXPro() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
const intent = mediaInfo.intent;
|
||
// mxPlayer Pro
|
||
const packageName = "com.mxtech.videoplayer.pro";
|
||
const url = `intent:${encodeURI(mediaInfo.streamUrl)}#Intent;package=${packageName};S.title=${encodeURI(intent.title)};i.position=${intent.position};end`;
|
||
console.log(url);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
async function embyNPlayer() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
let nUrl = OS.isMacOS()
|
||
? `nplayer-mac://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}&new_window=1`
|
||
: `nplayer-${encodeURI(mediaInfo.streamUrl)}`;
|
||
console.log(nUrl);
|
||
window.open(nUrl, "_self");
|
||
}
|
||
|
||
async function embyInfuse() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
// sub 参数限制: 播放带有外挂字幕的单个视频文件(Infuse 7.6.2 及以上版本)
|
||
// see: https://support.firecore.com/hc/zh-cn/articles/215090997
|
||
let infuseUrl = `infuse://x-callback-url/play?url=${encodeURIComponent(mediaInfo.streamUrl)}&sub=${encodeURIComponent(mediaInfo.subUrl)}`;
|
||
console.log(`infuseUrl= ${infuseUrl}`);
|
||
window.open(infuseUrl, "_self");
|
||
}
|
||
|
||
// StellarPlayer
|
||
async function embyStellarPlayer() {
|
||
let mediaInfo = await getEmbyMediaInfo();
|
||
let stellarPlayerUrl = `stellar://play/${encodeURI(mediaInfo.streamUrl)}`;
|
||
console.log(`stellarPlayerUrl= ${stellarPlayerUrl}`);
|
||
window.open(stellarPlayerUrl, "_self");
|
||
}
|
||
|
||
// see https://greasyfork.org/zh-CN/scripts/443916
|
||
async function embyDDPlay() {
|
||
// 检查是否windows本地路径
|
||
const fullPathEle = document.querySelector(".mediaSources .mediaSource .sectionTitle > div:not([class]):first-child");
|
||
let fullPath = fullPathEle ? fullPathEle.innerText : "";
|
||
let ddplayUrl;
|
||
if (new RegExp('^[a-zA-Z]:').test(fullPath)) {
|
||
ddplayUrl = `ddplay:${encodeURIComponent(fullPath)}`;
|
||
} else {
|
||
console.log("文件路径不是本地路径,将使用串流播放");
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
const intent = mediaInfo.intent;
|
||
if (!fullPath) {
|
||
fullPath = intent.title;
|
||
}
|
||
const urlPart = mediaInfo.streamUrl + `|filePath=${fullPath}`;
|
||
ddplayUrl = `ddplay:${encodeURIComponent(urlPart)}`;
|
||
if (OS.isAndroid()) {
|
||
// Subtitles Not Supported: https://github.com/kaedei/dandanplay-libraryindex/blob/master/api/ClientProtocol.md
|
||
ddplayUrl = `intent:${encodeURI(urlPart)}#Intent;package=com.xyoye.dandanplay;type=video/*;end`;
|
||
}
|
||
}
|
||
console.log(`ddplayUrl= ${ddplayUrl}`);
|
||
window.open(ddplayUrl, "_self");
|
||
}
|
||
|
||
async function embyFileball() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
// see: app 关于, URL Schemes
|
||
const url = `filebox://play?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
|
||
console.log(`FileballUrl= ${url}`);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
async function embyOmniPlayer() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
// see: https://github.com/AlistGo/alist-web/blob/main/src/pages/home/previews/video_box.tsx
|
||
const url = `omniplayer://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
|
||
console.log(`OmniPlayerUrl= ${url}`);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
async function embyFigPlayer() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
// see: https://github.com/AlistGo/alist-web/blob/main/src/pages/home/previews/video_box.tsx
|
||
const url = `figplayer://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
|
||
console.log(`FigPlayerUrl= ${url}`);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
async function embySenPlayer() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
// see: app 关于, URL Schemes
|
||
const url = `SenPlayer://x-callback-url/play?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
|
||
console.log(`SenPlayerUrl= ${url}`);
|
||
window.open(url, "_self");
|
||
}
|
||
|
||
function lsCheckSetBoolean(event, lsKeyName) {
|
||
let flag = localStorage.getItem(lsKeyName) === "1";
|
||
if (event) {
|
||
flag = !flag;
|
||
localStorage.setItem(lsKeyName, flag ? "1" : "0");
|
||
}
|
||
return flag;
|
||
}
|
||
|
||
function hideByOSHandler(event) {
|
||
const btn = document.getElementById("hideByOS");
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
const flag = lsCheckSetBoolean(event, lsKeys.hideByOS);
|
||
const playBtnsWrapper = document.getElementById(playBtnsWrapperId);
|
||
const buttonEleArr = playBtnsWrapper.querySelectorAll("button");
|
||
buttonEleArr.forEach(btnEle => {
|
||
const btn = playBtns.find(btn => btn.id === btnEle.id);
|
||
const shouldHide = flag && btn.osCheck && !btn.osCheck.some(check => check());
|
||
console.log(`${btn.id} Should Hide: ${shouldHide}`);
|
||
btnEle.style.display = shouldHide ? 'none' : 'block';
|
||
});
|
||
btn.classList.toggle("button-submit", flag);
|
||
}
|
||
|
||
function iconOnlyHandler(event) {
|
||
const btn = document.getElementById("iconOnly");
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
const flag = lsCheckSetBoolean(event, lsKeys.iconOnly);
|
||
const playBtnsWrapper = document.getElementById(playBtnsWrapperId);
|
||
const spans = playBtnsWrapper.querySelectorAll("span");
|
||
spans.forEach(span => {
|
||
span.hidden = flag;
|
||
});
|
||
const iArr = playBtnsWrapper.querySelectorAll("i");
|
||
iArr.forEach(iEle => {
|
||
iEle.classList.toggle("button-icon-left", !flag);
|
||
});
|
||
btn.classList.toggle("button-submit", flag);
|
||
}
|
||
|
||
function notCurrentPotHandler(event) {
|
||
const btn = document.getElementById("notCurrentPot");
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
const flag = lsCheckSetBoolean(event, lsKeys.notCurrentPot);
|
||
btn.classList.toggle("button-submit", flag);
|
||
}
|
||
|
||
function strmDirectHandler(event) {
|
||
const btn = document.getElementById("strmDirect");
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
const flag = lsCheckSetBoolean(event, lsKeys.strmDirect);
|
||
btn.classList.toggle("button-submit", flag);
|
||
}
|
||
|
||
async function embyCopyUrl() {
|
||
const mediaInfo = await getEmbyMediaInfo();
|
||
const streamUrl = encodeURI(mediaInfo.streamUrl);
|
||
if (await writeClipboard(streamUrl)) {
|
||
console.log(`copyUrl = ${streamUrl}`);
|
||
this.innerText = '复制成功';
|
||
}
|
||
}
|
||
|
||
async function writeClipboard(text) {
|
||
let flag = false;
|
||
if (navigator.clipboard) {
|
||
// 火狐上 need https
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
flag = true;
|
||
console.log("成功使用 navigator.clipboard 现代剪切板实现");
|
||
} catch (error) {
|
||
console.error('navigator.clipboard 复制到剪贴板时发生错误:', error);
|
||
}
|
||
} else {
|
||
flag = writeClipboardLegacy(text);
|
||
console.log("不存在 navigator.clipboard 现代剪切板实现,使用旧版实现");
|
||
}
|
||
return flag;
|
||
}
|
||
|
||
function writeClipboardLegacy(text) {
|
||
let textarea = document.createElement('textarea');
|
||
document.body.appendChild(textarea);
|
||
textarea.style.position = 'absolute';
|
||
textarea.style.clip = 'rect(0 0 0 0)';
|
||
textarea.value = text;
|
||
textarea.select();
|
||
if (document.execCommand('copy', true)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// emby/jellyfin CustomEvent
|
||
// see: https://github.com/MediaBrowser/emby-web-defaultskin/blob/822273018b82a4c63c2df7618020fb837656868d/nowplaying/videoosd.js#L691
|
||
// monitor dom changements
|
||
document.addEventListener("viewbeforeshow", function (e) {
|
||
console.log("viewbeforeshow", e);
|
||
if (isEmby === "") {
|
||
isEmby = !!e.detail.contextPath;
|
||
}
|
||
let isItemDetailPage;
|
||
if (isEmby) {
|
||
isItemDetailPage = e.detail.contextPath.startsWith("/item?id=");
|
||
} else {
|
||
isItemDetailPage = e.detail.params && e.detail.params.id;
|
||
}
|
||
if (isItemDetailPage) {
|
||
const mutation = new MutationObserver(function() {
|
||
if (showFlag()) {
|
||
init();
|
||
mutation.disconnect();
|
||
}
|
||
})
|
||
mutation.observe(document.body, {
|
||
childList: true,
|
||
characterData: true,
|
||
subtree: true,
|
||
})
|
||
}
|
||
});
|
||
|
||
})();
|