diff --git a/embysim.tar.xz b/embysim.tar.xz
index 753eb57..54ccd35 100644
Binary files a/embysim.tar.xz and b/embysim.tar.xz differ
diff --git a/externalPlayer.js b/externalPlayer.js
new file mode 100644
index 0000000..9be02c8
--- /dev/null
+++ b/externalPlayer.js
@@ -0,0 +1,700 @@
+// ==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 `
+
+ `;
+ }
+ let buttonHtml = `
`;
+ playBtns.forEach(btn => {
+ buttonHtml += generateButtonHTML(btn);
+ });
+ buttonHtml += `
`;
+
+ 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,
+ })
+ }
+ });
+
+})();
diff --git a/install.sh b/install.sh
index de05896..b7b6a8e 100644
--- a/install.sh
+++ b/install.sh
@@ -47,8 +47,8 @@ if [ ! -e emby.config/config/system.xml ]; then
fi
echo "copy scripts..."
-cp -a ../webdav_simulator.amd64 ../htdocs ../runwebdavsim.sh ../killwebdavsim.sh ../tmuxrunwebdavsim.sh ../attach.sh ../rclonemount.sh ../runembysim.sh ../rclone.conf ../xy*.txt.xz .
-rm -f ../webdav_simulator.amd64 ../htdocs ../runwebdavsim.sh ../killwebdavsim.sh ../tmuxrunwebdavsim.sh ../attach.sh ../rclonemount.sh ../runembysim.sh ../rclone.conf ../xy*.txt.xz
+cp -a ../webdav_simulator.amd64 ../htdocs ../runwebdavsim.sh ../killwebdavsim.sh ../tmuxrunwebdavsim.sh ../attach.sh ../rclonemount.sh ../runembysim.sh ../rclone.conf ../xy*.txt.xz ../externalPlayer.js .
+rm -f ../webdav_simulator.amd64 ../htdocs ../runwebdavsim.sh ../killwebdavsim.sh ../tmuxrunwebdavsim.sh ../attach.sh ../rclonemount.sh ../runembysim.sh ../rclone.conf ../xy*.txt.xz ../externalPlayer.js
chmod u+x *.sh
echo "copy scripts finish"
diff --git a/runembysim.sh b/runembysim.sh
index a6cf0e0..0c40e4d 100644
--- a/runembysim.sh
+++ b/runembysim.sh
@@ -16,6 +16,15 @@ progdir=`dirname "${prog}"`
cd "${progdir}"
progdir=$(pwd)
+# 获取当前脚本的名称
+SCRIPT_NAME=$(basename "$0")
+
+# 检查是否有相同的脚本在运行(排除当前进程)
+if pgrep -f "$SCRIPT_NAME" | grep -v "$$" > /dev/null; then
+ echo "脚本已在运行,退出..."
+ exit 1
+fi
+
mkdir -p emby.tmp emby.cache >/dev/null 2>&1
chmod 777 emby.tmp emby.cache
@@ -104,3 +113,7 @@ docker run -d \
emby/embyserver:latest
#pandagroove/embysim:1.0.0
+
+
+docker cp "externalPlayer.js" "embysim":"/system/dashboard-ui/externalPlayer.js"
+docker exec "embysim" sh -c "sed -i -e 's|