embyLaunchPotplayer

emby/jellfin launch extetnal player

目前为 2025-01-17 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name embyLaunchPotplayer
  3. // @name:en embyLaunchPotplayer
  4. // @name:zh embyLaunchPotplayer
  5. // @name:zh-CN embyLaunchPotplayer
  6. // @namespace http://tampermonkey.net/
  7. // @version 1.1.18
  8. // @description emby/jellfin launch extetnal player
  9. // @description:zh-cn emby/jellfin 调用外部播放器
  10. // @description:en emby/jellfin to external player
  11. // @license MIT
  12. // @author @bpking
  13. // @github https://github.com/bpking1/embyExternalUrl
  14. // @match *://*/web/index.html
  15. // @match *://*/web/
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20. const iconConfig = {
  21. // 图标来源,以下三选一,注释为只留一个,3 的优先级最高
  22. // 1.add icons from jsdelivr, network
  23. baseUrl: "https://emby-external-url.7o7o.cc/embyWebAddExternalUrl/icons",
  24. // baseUrl: "https://fastly.jsdelivr.net/gh/bpking1/embyExternalUrl@main/embyWebAddExternalUrl/icons",
  25. // 2.server local icons, same as /emby-server/system/dashboard-ui/icons
  26. // baseUrl: "icons",
  27. // 3.add icons from Base64, script inner, this script size 22.5KB to 74KB,
  28. // 自行复制 ./iconsExt.js 内容到此脚本的 getIconsExt 中
  29. };
  30. // 启用后将修改直接串流链接为真实文件名,方便第三方播放器友好显示和匹配,
  31. // 默认不启用,强依赖 nginx-emby2Alist location two rewrite,如发现原始链接播放失败,请关闭此选项
  32. const useRealFileName = false;
  33. // 以下为内部使用变量,请勿更改
  34. let isEmby = "";
  35. const mark = "embyLaunchPotplayer";
  36. const playBtnsWrapperId = "ExternalPlayersBtns";
  37. const lsKeys = {
  38. iconOnly: `${mark}-iconOnly`,
  39. hideByOS: `${mark}-hideByOS`,
  40. notCurrentPot: `${mark}-notCurrentPot`,
  41. };
  42. const OS = {
  43. isAndroid: () => /android/i.test(navigator.userAgent),
  44. isIOS: () => /iPad|iPhone|iPod/i.test(navigator.userAgent),
  45. isMacOS: () => /Macintosh|MacIntel/i.test(navigator.userAgent),
  46. isApple: () => OS.isMacOS() || OS.isIOS(),
  47. isWindows: () => /compatible|Windows/i.test(navigator.userAgent),
  48. isMobile: () => OS.isAndroid() || OS.isIOS(),
  49. isUbuntu: () => /Ubuntu/i.test(navigator.userAgent),
  50. // isAndroidEmbyNoisyX: () => OS.isAndroid() && ApiClient.appVersion().includes('-'),
  51. // isEmbyNoisyX: () => ApiClient.appVersion().includes('-'),
  52. isOthers: () => Object.entries(OS).filter(([key, val]) => key !== 'isOthers').every(([key, val]) => !val()),
  53. };
  54. const playBtns = [
  55. { id: "embyPot", title: "Potplayer", iconId: "icon-PotPlayer"
  56. , onClick: embyPot, osCheck: [OS.isWindows], },
  57. { id: "embyVlc", title: "VLC", iconId: "icon-VLC", onClick: embyVlc, },
  58. { id: "embyIINA", title: "IINA", iconId: "icon-IINA"
  59. , onClick: embyIINA, osCheck: [OS.isMacOS], },
  60. { id: "embyNPlayer", title: "NPlayer", iconId: "icon-NPlayer", onClick: embyNPlayer, },
  61. { id: "embyMX", title: "MXPlayer", iconId: "icon-MXPlayer"
  62. , onClick: embyMX, osCheck: [OS.isAndroid], },
  63. { id: "embyMXPro", title: "MXPlayerPro", iconId: "icon-MXPlayerPro"
  64. , onClick: embyMXPro, osCheck: [OS.isAndroid], },
  65. { id: "embyInfuse", title: "Infuse", iconId: "icon-infuse"
  66. , onClick: embyInfuse, osCheck: [OS.isApple], },
  67. { id: "embyStellarPlayer", title: "恒星播放器", iconId: "icon-StellarPlayer"
  68. , onClick: embyStellarPlayer, osCheck: [OS.isWindows, OS.isMacOS, OS.isAndroid], },
  69. { id: "embyMPV", title: "MPV", iconId: "icon-MPV", onClick: embyMPV, },
  70. { id: "embyDDPlay", title: "弹弹Play", iconId: "icon-DDPlay"
  71. , onClick: embyDDPlay, osCheck: [OS.isWindows, OS.isAndroid], },
  72. { id: "embyFileball", title: "Fileball", iconId: "icon-Fileball"
  73. , onClick: embyFileball, osCheck: [OS.isApple], },
  74. { id: "embyOmniPlayer", title: "OmniPlayer", iconId: "icon-OmniPlayer"
  75. , onClick: embyOmniPlayer, osCheck: [OS.isMacOS], },
  76. { id: "embyFigPlayer", title: "FigPlayer", iconId: "icon-FigPlayer"
  77. , onClick: embyFigPlayer, osCheck: [OS.isMacOS], },
  78. { id: "embySenPlayer", title: "SenPlayer", iconId: "icon-SenPlayer"
  79. , onClick: embySenPlayer, osCheck: [OS.isIOS], },
  80. { id: "embyCopyUrl", title: "复制串流地址", iconId: "icon-Copy", onClick: embyCopyUrl, },
  81. { id: "hideByOS", title: "异构播放器", iconId: "", onClick: hideByOSHandler, },
  82. { id: "iconOnly", title: "显示模式", iconId: "", onClick: iconOnlyHandler, },
  83. { id: "notCurrentPot", title: "多开Potplayer", iconId: "", onClick: notCurrentPotHandler , },
  84. ];
  85.  
  86. function init() {
  87. let playBtnsWrapper = document.getElementById(playBtnsWrapperId);
  88. if (playBtnsWrapper) {
  89. playBtnsWrapper.remove();
  90. }
  91. let mainDetailButtons = document.querySelector("div[is='emby-scroller']:not(.hide) .mainDetailButtons");
  92. function generateButtonHTML({ id, title, iconId }) {
  93. return `
  94. <button
  95. id="${id}"
  96. type="button"
  97. class="detailButton emby-button emby-button-backdropfilter raised-backdropfilter detailButton-primary"
  98. title="${title}"
  99. >
  100. <div class="detailButton-content">
  101. <i class="md-icon detailButton-icon button-icon button-icon-left" id="${iconId}"> </i>
  102. <span class="button-text">${title}</span>
  103. </div>
  104. </button>
  105. `;
  106. }
  107. let buttonHtml = `<div id="${playBtnsWrapperId}" class="detailButtons flex align-items-flex-start flex-wrap-wrap">`;
  108. playBtns.forEach(btn => {
  109. buttonHtml += generateButtonHTML(btn);
  110. });
  111. buttonHtml += `</div>`;
  112.  
  113. if (!isEmby) {
  114. // jellfin
  115. mainDetailButtons = document.querySelector("div.itemDetailPage:not(.hide) div.detailPagePrimaryContainer");
  116. }
  117.  
  118. mainDetailButtons.insertAdjacentHTML("afterend", buttonHtml);
  119.  
  120. if (!isEmby) {
  121. // jellfin add class, detailPagePrimaryContainer、button-flat
  122. let playBtnsWrapper = document.getElementById("ExternalPlayersBtns");
  123. // style to cover .layout-mobile
  124. playBtnsWrapper.style.display = "flex";
  125. // playBtnsWrapper.style["justifyContent"] = "center";
  126. playBtnsWrapper.classList.add("detailPagePrimaryContainer");
  127. let btns = playBtnsWrapper.getElementsByTagName("button");
  128. for (let i = 0; i < btns.length; i++) {
  129. btns[i].classList.add("button-flat");
  130. }
  131. }
  132.  
  133. // add event
  134. playBtns.forEach(btn => {
  135. const btnEle = document.querySelector(`#${btn.id}`);
  136. if (btnEle) {
  137. btnEle.onclick = btn.onClick;
  138. }
  139. });
  140.  
  141. const iconBaseUrl = iconConfig.baseUrl;
  142. const icons = [
  143. // if url exists, use url property, if id diff icon name, use name property
  144. { id: "icon-PotPlayer", name: "icon-PotPlayer.webp", fontSize: "1.4em" },
  145. { id: "icon-VLC", fontSize: "1.3em" },
  146. { id: "icon-IINA", fontSize: "1.4em" },
  147. { id: "icon-NPlayer", fontSize: "1.3em" },
  148. { id: "icon-MXPlayer", fontSize: "1.4em" },
  149. { id: "icon-MXPlayerPro", fontSize: "1.4em" },
  150. { id: "icon-infuse", fontSize: "1.4em" },
  151. { id: "icon-StellarPlayer", fontSize: "1.4em" },
  152. { id: "icon-MPV", fontSize: "1.4em" },
  153. { id: "icon-DDPlay", fontSize: "1.4em" },
  154. { id: "icon-Fileball", fontSize: "1.4em" },
  155. { id: "icon-SenPlayer", fontSize: "1.4em" },
  156. { id: "icon-OmniPlayer", fontSize: "1.4em" },
  157. { id: "icon-FigPlayer", fontSize: "1.4em" },
  158. { id: "icon-Copy", fontSize: "1.4em" },
  159. ];
  160. const iconsExt = getIconsExt();
  161. icons.map((icon, index) => {
  162. const element = document.querySelector(`#${icon.id}`);
  163. if (element) {
  164. // if url exists, use url property, if id diff icon name, use name property
  165. icon.url = typeof iconsExt !== 'undefined' && iconsExt && iconsExt[index] ? iconsExt[index].url : undefined;
  166. const url = icon.url || `${iconBaseUrl}/${icon.name || `${icon.id}.webp`}`;
  167. element.style.cssText += `
  168. background-image: url(${url});
  169. background-repeat: no-repeat;
  170. background-size: 100% 100%;
  171. font-size: ${icon.fontSize};
  172. `;
  173. }
  174. });
  175. hideByOSHandler();
  176. iconOnlyHandler();
  177. notCurrentPotHandler();
  178. }
  179.  
  180. // copy from ./iconsExt,如果更改了以下内容,请同步更改 ./iconsExt.js
  181. function getIconsExt() {
  182. // base64 data total size 72.5 KB from embyWebAddExternalUrl/icons/min, sync modify
  183. const iconsExt = [];
  184. return iconsExt;
  185. }
  186.  
  187. function showFlag() {
  188. // itemMiscInfo-primary
  189. // 评分,上映日期信息栏
  190. let mediaInfoPrimary = document.querySelector("div[is='emby-scroller']:not(.hide) .mediaInfoPrimary:not(.hide)");
  191. // 创建录制按钮
  192. let btnManualRecording = document.querySelector("div[is='emby-scroller']:not(.hide) .btnManualRecording:not(.hide)");
  193. if (!isEmby) {
  194. mediaInfoPrimary = document.querySelector(".itemMiscInfo-primary:not(.hide)");
  195. // 停止录制按钮
  196. btnManualRecording = document.querySelector(".btnCancelTimer:not(.hide)");
  197. }
  198. return !!mediaInfoPrimary || !!btnManualRecording;
  199. }
  200.  
  201. async function getItemInfo() {
  202. let userId = ApiClient._serverInfo.UserId;
  203. let itemId = /\?id=([A-Za-z0-9]+)/.exec(window.location.hash)[1];
  204. let response = await ApiClient.getItem(userId, itemId);
  205. // 继续播放当前剧集的下一集
  206. if (response.Type == "Series") {
  207. let seriesNextUpItems = await ApiClient.getNextUpEpisodes({ SeriesId: itemId, UserId: userId });
  208. if (seriesNextUpItems.Items.length > 0) {
  209. console.log("nextUpItemId: " + seriesNextUpItems.Items[0].Id);
  210. return await ApiClient.getItem(userId, seriesNextUpItems.Items[0].Id);
  211. }
  212. }
  213. // 播放当前季season的第一集
  214. if (response.Type == "Season") {
  215. let seasonItems = await ApiClient.getItems(userId, { parentId: itemId });
  216. console.log("seasonItemId: " + seasonItems.Items[0].Id);
  217. return await ApiClient.getItem(userId, seasonItems.Items[0].Id);
  218. }
  219. // 播放当前集或电影
  220. if (response.MediaSources?.length > 0) {
  221. console.log("itemId: " + itemId);
  222. return response;
  223. }
  224. // 默认播放第一个,集/播放列表第一个媒体
  225. let firstItems = await ApiClient.getItems(userId, { parentId: itemId, Recursive: true, IsFolder: false, Limit: 1 });
  226. console.log("firstItemId: " + firstItems.Items[0].Id);
  227. return await ApiClient.getItem(userId, firstItems.Items[0].Id);
  228. }
  229.  
  230. function getSeek(position) {
  231. let ticks = position * 10000;
  232. let parts = []
  233. , hours = ticks / 36e9;
  234. (hours = Math.floor(hours)) && parts.push(hours);
  235. let minutes = (ticks -= 36e9 * hours) / 6e8;
  236. ticks -= 6e8 * (minutes = Math.floor(minutes)),
  237. minutes < 10 && hours && (minutes = "0" + minutes),
  238. parts.push(minutes);
  239. let seconds = ticks / 1e7;
  240. return (seconds = Math.floor(seconds)) < 10 && (seconds = "0" + seconds),
  241. parts.push(seconds),
  242. parts.join(":")
  243. }
  244.  
  245. function getSubPath(mediaSource) {
  246. let selectSubtitles = document.querySelector("div[is='emby-scroller']:not(.hide) select.selectSubtitles");
  247. let subTitlePath = '';
  248. //返回选中的外挂字幕
  249. if (selectSubtitles && selectSubtitles.value > 0) {
  250. let SubIndex = mediaSource.MediaStreams.findIndex(m => m.Index == selectSubtitles.value && m.IsExternal);
  251. if (SubIndex > -1) {
  252. let subtitleCodec = mediaSource.MediaStreams[SubIndex].Codec;
  253. subTitlePath = `/${mediaSource.Id}/Subtitles/${selectSubtitles.value}/Stream.${subtitleCodec}`;
  254. }
  255. }
  256. else {
  257. //默认尝试返回第一个外挂中文字幕
  258. let chiSubIndex = mediaSource.MediaStreams.findIndex(m => m.Language == "chi" && m.IsExternal);
  259. if (chiSubIndex > -1) {
  260. let subtitleCodec = mediaSource.MediaStreams[chiSubIndex].Codec;
  261. subTitlePath = `/${mediaSource.Id}/Subtitles/${chiSubIndex}/Stream.${subtitleCodec}`;
  262. } else {
  263. //尝试返回第一个外挂字幕
  264. let externalSubIndex = mediaSource.MediaStreams.findIndex(m => m.IsExternal);
  265. if (externalSubIndex > -1) {
  266. let subtitleCodec = mediaSource.MediaStreams[externalSubIndex].Codec;
  267. subTitlePath = `/${mediaSource.Id}/Subtitles/${externalSubIndex}/Stream.${subtitleCodec}`;
  268. }
  269. }
  270.  
  271. }
  272. return subTitlePath;
  273. }
  274.  
  275. async function getEmbyMediaInfo() {
  276. let itemInfo = await getItemInfo();
  277. let mediaSourceId = itemInfo.MediaSources[0].Id;
  278. let selectSource = document.querySelector("div[is='emby-scroller']:not(.hide) select.selectSource:not([disabled])");
  279. if (selectSource && selectSource.value.length > 0) {
  280. mediaSourceId = selectSource.value;
  281. }
  282. // let selectAudio = document.querySelector("div[is='emby-scroller']:not(.hide) select.selectAudio:not([disabled])");
  283. let mediaSource = itemInfo.MediaSources.find(m => m.Id == mediaSourceId);
  284. let uri = isEmby ? "/emby/videos" : "/Items";
  285. let domain = `${ApiClient._serverAddress}${uri}/${itemInfo.Id}`;
  286. let subPath = getSubPath(mediaSource);
  287. let subUrl = subPath.length > 0 ? `${domain}${subPath}?api_key=${ApiClient.accessToken()}` : '';
  288. let streamUrl = `${domain}/`;
  289. let fileName = mediaSource.IsInfiniteStream ? `master.m3u8` : mediaSource.Path.replace(/.*[\\/]/, "");
  290. if (isEmby) {
  291. if (mediaSource.IsInfiniteStream) {
  292. streamUrl += useRealFileName && mediaSource.Name ? `${mediaSource.Name}.m3u8` : fileName;
  293. } else {
  294. // origin link: /emby/videos/401929/stream.xxx?xxx
  295. // modify link: /emby/videos/401929/stream/xxx.xxx?xxx
  296. // this is not important, hit "/emby/videos/401929/" path level still worked
  297. streamUrl += useRealFileName ? `stream/${fileName}` : `stream.${mediaSource.Container}`;
  298. }
  299. } else {
  300. streamUrl += `Download`;
  301. streamUrl += useRealFileName ? `/${fileName}` : "";
  302. }
  303. streamUrl += `?api_key=${ApiClient.accessToken()}&Static=true&MediaSourceId=${mediaSourceId}&DeviceId=${ApiClient._deviceId}`;
  304. let position = parseInt(itemInfo.UserData.PlaybackPositionTicks / 10000);
  305. let intent = await getIntent(mediaSource, position);
  306. console.log(streamUrl, subUrl, intent);
  307. return {
  308. streamUrl: streamUrl,
  309. subUrl: subUrl,
  310. intent: intent,
  311. }
  312. }
  313.  
  314. async function getIntent(mediaSource, position) {
  315. // 直播节目查询items接口没有path
  316. let title = mediaSource.IsInfiniteStream
  317. ? mediaSource.Name
  318. : mediaSource.Path.split('/').pop();
  319. let externalSubs = mediaSource.MediaStreams.filter(m => m.IsExternal == true);
  320. let subs = ''; //要求是android.net.uri[] ?
  321. let subs_name = '';
  322. let subs_filename = '';
  323. let subs_enable = '';
  324. if (externalSubs) {
  325. subs_name = externalSubs.map(s => s.DisplayTitle);
  326. subs_filename = externalSubs.map(s => s.Path.split('/').pop());
  327. }
  328. return {
  329. title: title,
  330. position: position,
  331. subs: subs,
  332. subs_name: subs_name,
  333. subs_filename: subs_filename,
  334. subs_enable: subs_enable
  335. };
  336. }
  337.  
  338. // URL with "intent" scheme 只支持
  339. // String => 'S'
  340. // Boolean =>'B'
  341. // Byte => 'b'
  342. // Character => 'c'
  343. // Double => 'd'
  344. // Float => 'f'
  345. // Integer => 'i'
  346. // Long => 'l'
  347. // Short => 's'
  348.  
  349. async function embyPot() {
  350. const mediaInfo = await getEmbyMediaInfo();
  351. const intent = mediaInfo.intent;
  352. const notCurrentPotArg = localStorage.getItem(lsKeys.notCurrentPot) === "1" ? "" : "/current";
  353. let potUrl = `potplayer://${encodeURI(mediaInfo.streamUrl)} /sub=${encodeURI(mediaInfo.subUrl)} ${notCurrentPotArg} /seek=${getSeek(intent.position)} /title="${intent.title}"`;
  354. await writeClipboard(potUrl);
  355. console.log("成功写入剪切板真实深度链接: ", potUrl);
  356. // 测试出无空格也行,potplayer 对于 DeepLink 会自动转换为命令行参数,全量参数: PotPlayer 关于 => 命令行选项
  357. potUrl = `potplayer://${notCurrentPotArg}/clipboard`;
  358. window.open(potUrl, "_self");
  359. }
  360.  
  361. /**
  362. * 这是一个临时解决方案,所以此段判断仅在 Google Chrome 浏览器下使用,区别 {brand: 'Microsoft Edge', version: '130'}
  363. * 非 Chrome 内核无 userAgentData 对象, Chrome 内核套壳的没添加 brands 品牌元素
  364. */
  365. // function geGoogleChrome130() {
  366. // if (!navigator.userAgentData) { return false; }
  367. // const googleBrand = navigator.userAgentData.brands.find(b => b.brand === "Google Chrome");
  368. // if (!googleBrand) { return false; }
  369. // return parseInt(googleBrand.version) >= 130;
  370. // }
  371.  
  372. // async function embyPot() {
  373. // let mediaInfo = await getEmbyMediaInfo();
  374. // let intent = mediaInfo.intent;
  375. // let potUrl = `potplayer://${encodeURI(mediaInfo.streamUrl)} /sub=${encodeURI(mediaInfo.subUrl)} /current /seek=${getSeek(intent.position)}`;
  376. // potUrl += useRealFileName ? '' : ` /title="${intent.title}"`;
  377. // console.log(potUrl);
  378. // window.open(potUrl, "_self");
  379. // }
  380.  
  381. // https://wiki.videolan.org/Android_Player_Intents/
  382. async function embyVlc() {
  383. let mediaInfo = await getEmbyMediaInfo();
  384. let intent = mediaInfo.intent;
  385. // android subtitles: https://code.videolan.org/videolan/vlc-android/-/issues/1903
  386. 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`;
  387. if (OS.isWindows()) {
  388. // 桌面端需要额外设置,参考这个项目: https://github.com/stefansundin/vlc-protocol
  389. vlcUrl = `vlc://${encodeURI(mediaInfo.streamUrl)}`;
  390. }
  391. if (OS.isIOS()) {
  392. // https://wiki.videolan.org/Documentation:IOS/#x-callback-url
  393. // https://code.videolan.org/videolan/vlc-ios/-/commit/55e27ed69e2fce7d87c47c9342f8889fda356aa9
  394. vlcUrl = `vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(mediaInfo.streamUrl)}&sub=${encodeURIComponent(mediaInfo.subUrl)}`;
  395. }
  396. console.log(vlcUrl);
  397. window.open(vlcUrl, "_self");
  398. }
  399.  
  400. // https://github.com/iina/iina/issues/1991
  401. async function embyIINA() {
  402. let mediaInfo = await getEmbyMediaInfo();
  403. let iinaUrl = `iina://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}&new_window=1`;
  404. console.log(`iinaUrl= ${iinaUrl}`);
  405. window.open(iinaUrl, "_self");
  406. }
  407.  
  408. // https://sites.google.com/site/mxvpen/api
  409. // https://mx.j2inter.com/api
  410. // https://support.mxplayer.in/support/solutions/folders/43000574903
  411. async function embyMX() {
  412. const mediaInfo = await getEmbyMediaInfo();
  413. const intent = mediaInfo.intent;
  414. // mxPlayer free
  415. const packageName = "com.mxtech.videoplayer.ad";
  416. const url = `intent:${encodeURI(mediaInfo.streamUrl)}#Intent;package=${packageName};S.title=${encodeURI(intent.title)};i.position=${intent.position};end`;
  417. console.log(url);
  418. window.open(url, "_self");
  419. }
  420.  
  421. async function embyMXPro() {
  422. const mediaInfo = await getEmbyMediaInfo();
  423. const intent = mediaInfo.intent;
  424. // mxPlayer Pro
  425. const packageName = "com.mxtech.videoplayer.pro";
  426. const url = `intent:${encodeURI(mediaInfo.streamUrl)}#Intent;package=${packageName};S.title=${encodeURI(intent.title)};i.position=${intent.position};end`;
  427. console.log(url);
  428. window.open(url, "_self");
  429. }
  430.  
  431. async function embyNPlayer() {
  432. let mediaInfo = await getEmbyMediaInfo();
  433. let nUrl = OS.isMacOS()
  434. ? `nplayer-mac://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}&new_window=1`
  435. : `nplayer-${encodeURI(mediaInfo.streamUrl)}`;
  436. console.log(nUrl);
  437. window.open(nUrl, "_self");
  438. }
  439.  
  440. async function embyInfuse() {
  441. let mediaInfo = await getEmbyMediaInfo();
  442. // sub 参数限制: 播放带有外挂字幕的单个视频文件(Infuse 7.6.2 及以上版本)
  443. // see: https://support.firecore.com/hc/zh-cn/articles/215090997
  444. let infuseUrl = `infuse://x-callback-url/play?url=${encodeURIComponent(mediaInfo.streamUrl)}&sub=${encodeURIComponent(mediaInfo.subUrl)}`;
  445. console.log(`infuseUrl= ${infuseUrl}`);
  446. window.open(infuseUrl, "_self");
  447. }
  448.  
  449. // StellarPlayer
  450. async function embyStellarPlayer() {
  451. let mediaInfo = await getEmbyMediaInfo();
  452. let stellarPlayerUrl = `stellar://play/${encodeURI(mediaInfo.streamUrl)}`;
  453. console.log(`stellarPlayerUrl= ${stellarPlayerUrl}`);
  454. window.open(stellarPlayerUrl, "_self");
  455. }
  456.  
  457. // MPV
  458. async function embyMPV() {
  459. let mediaInfo = await getEmbyMediaInfo();
  460. //桌面端需要额外设置,使用这个项目: https://github.com/akiirui/mpv-handler
  461. let streamUrl64 = btoa(String.fromCharCode.apply(null, new Uint8Array(new TextEncoder().encode(mediaInfo.streamUrl))))
  462. .replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
  463. let MPVUrl = `mpv://play/${streamUrl64}`;
  464. if (mediaInfo.subUrl.length > 0) {
  465. let subUrl64 = btoa(mediaInfo.subUrl).replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
  466. MPVUrl = `mpv://play/${streamUrl64}/?subfile=${subUrl64}`;
  467. }
  468.  
  469. if (OS.isIOS() || OS.isAndroid()) {
  470. MPVUrl = `mpv://${encodeURI(mediaInfo.streamUrl)}`;
  471. }
  472.  
  473. console.log(MPVUrl);
  474. window.open(MPVUrl, "_self");
  475. }
  476.  
  477. // see https://gf.qytechs.cn/zh-CN/scripts/443916
  478. async function embyDDPlay() {
  479. // 检查是否windows本地路径
  480. const fullPathEle = document.querySelector(".mediaSources .mediaSource .sectionTitle > div:not([class]):first-child");
  481. let fullPath = fullPathEle ? fullPathEle.innerText : "";
  482. let ddplayUrl;
  483. if (new RegExp('^[a-zA-Z]:').test(fullPath)) {
  484. ddplayUrl = `ddplay:${encodeURIComponent(fullPath)}`;
  485. } else {
  486. console.log("文件路径不是本地路径,将使用串流播放");
  487. const mediaInfo = await getEmbyMediaInfo();
  488. const intent = mediaInfo.intent;
  489. if (!fullPath) {
  490. fullPath = intent.title;
  491. }
  492. const urlPart = mediaInfo.streamUrl + `|filePath=${fullPath}`;
  493. ddplayUrl = `ddplay:${encodeURIComponent(urlPart)}`;
  494. if (OS.isAndroid()) {
  495. // Subtitles Not Supported: https://github.com/kaedei/dandanplay-libraryindex/blob/master/api/ClientProtocol.md
  496. ddplayUrl = `intent:${encodeURI(urlPart)}#Intent;package=com.xyoye.dandanplay;type=video/*;end`;
  497. }
  498. }
  499. console.log(`ddplayUrl= ${ddplayUrl}`);
  500. window.open(ddplayUrl, "_self");
  501. }
  502.  
  503. async function embyFileball() {
  504. const mediaInfo = await getEmbyMediaInfo();
  505. // see: app 关于, URL Schemes
  506. const url = `filebox://play?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
  507. console.log(`FileballUrl= ${url}`);
  508. window.open(url, "_self");
  509. }
  510.  
  511. async function embyOmniPlayer() {
  512. const mediaInfo = await getEmbyMediaInfo();
  513. // see: https://github.com/AlistGo/alist-web/blob/main/src/pages/home/previews/video_box.tsx
  514. const url = `omniplayer://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
  515. console.log(`OmniPlayerUrl= ${url}`);
  516. window.open(url, "_self");
  517. }
  518.  
  519. async function embyFigPlayer() {
  520. const mediaInfo = await getEmbyMediaInfo();
  521. // see: https://github.com/AlistGo/alist-web/blob/main/src/pages/home/previews/video_box.tsx
  522. const url = `figplayer://weblink?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
  523. console.log(`FigPlayerUrl= ${url}`);
  524. window.open(url, "_self");
  525. }
  526.  
  527. async function embySenPlayer() {
  528. const mediaInfo = await getEmbyMediaInfo();
  529. // see: app 关于, URL Schemes
  530. const url = `SenPlayer://x-callback-url/play?url=${encodeURIComponent(mediaInfo.streamUrl)}`;
  531. console.log(`SenPlayerUrl= ${url}`);
  532. window.open(url, "_self");
  533. }
  534.  
  535. function lsCheckSetBoolean(event, lsKeyName) {
  536. let flag = localStorage.getItem(lsKeyName) === "1";
  537. if (event) {
  538. flag = !flag;
  539. localStorage.setItem(lsKeyName, flag ? "1" : "0");
  540. }
  541. return flag;
  542. }
  543.  
  544. function hideByOSHandler(event) {
  545. const flag = lsCheckSetBoolean(event, lsKeys.hideByOS);
  546. const playBtnsWrapper = document.getElementById(playBtnsWrapperId);
  547. const buttonEleArr = playBtnsWrapper.querySelectorAll("button");
  548. buttonEleArr.forEach(btnEle => {
  549. const btn = playBtns.find(btn => btn.id === btnEle.id);
  550. const shouldHide = flag && btn.osCheck && !btn.osCheck.some(check => check());
  551. console.log(`${btn.id} Should Hide: ${shouldHide}`);
  552. btnEle.style.display = shouldHide ? 'none' : 'block';
  553. });
  554. const btn = document.getElementById("hideByOS");
  555. btn.classList.toggle("button-submit", flag);
  556. }
  557.  
  558. function iconOnlyHandler(event) {
  559. const flag = lsCheckSetBoolean(event, lsKeys.iconOnly);
  560. const playBtnsWrapper = document.getElementById(playBtnsWrapperId);
  561. const spans = playBtnsWrapper.querySelectorAll("span");
  562. spans.forEach(span => {
  563. span.hidden = flag;
  564. });
  565. const iArr = playBtnsWrapper.querySelectorAll("i");
  566. iArr.forEach(iEle => {
  567. iEle.classList.toggle("button-icon-left", !flag);
  568. });
  569. const btn = document.getElementById("iconOnly");
  570. btn.classList.toggle("button-submit", flag);
  571. }
  572.  
  573. function notCurrentPotHandler(event) {
  574. const flag = lsCheckSetBoolean(event, lsKeys.notCurrentPot);
  575. const btn = document.getElementById("notCurrentPot");
  576. btn.classList.toggle("button-submit", flag);
  577. }
  578.  
  579. async function embyCopyUrl() {
  580. const mediaInfo = await getEmbyMediaInfo();
  581. const streamUrl = encodeURI(mediaInfo.streamUrl);
  582. if (await writeClipboard(streamUrl)) {
  583. console.log(`decodeURI for show copyUrl = ${mediaInfo.streamUrl}`);
  584. this.innerText = '复制成功';
  585. }
  586. }
  587.  
  588. async function writeClipboard(text) {
  589. let flag = false;
  590. if (navigator.clipboard) {
  591. // 火狐上 need https
  592. try {
  593. await navigator.clipboard.writeText(text);
  594. flag = true;
  595. console.log("成功使用 navigator.clipboard 现代剪切板实现");
  596. } catch (error) {
  597. console.error('navigator.clipboard 复制到剪贴板时发生错误:', error);
  598. }
  599. } else {
  600. flag = writeClipboardLegacy(text);
  601. console.log("不存在 navigator.clipboard 现代剪切板实现,使用旧版实现");
  602. }
  603. return flag;
  604. }
  605.  
  606. function writeClipboardLegacy(text) {
  607. let textarea = document.createElement('textarea');
  608. document.body.appendChild(textarea);
  609. textarea.style.position = 'absolute';
  610. textarea.style.clip = 'rect(0 0 0 0)';
  611. textarea.value = text;
  612. textarea.select();
  613. if (document.execCommand('copy', true)) {
  614. return true;
  615. }
  616. return false;
  617. }
  618.  
  619. // emby/jellyfin CustomEvent
  620. // see: https://github.com/MediaBrowser/emby-web-defaultskin/blob/822273018b82a4c63c2df7618020fb837656868d/nowplaying/videoosd.js#L691
  621. // monitor dom changements
  622. document.addEventListener("viewbeforeshow", function (e) {
  623. console.log("viewbeforeshow", e);
  624. if (isEmby === "") {
  625. isEmby = !!e.detail.contextPath;
  626. }
  627. let isItemDetailPage;
  628. if (isEmby) {
  629. isItemDetailPage = e.detail.contextPath.startsWith("/item?id=");
  630. } else {
  631. isItemDetailPage = e.detail.params && e.detail.params.id;
  632. }
  633. if (isItemDetailPage) {
  634. const mutation = new MutationObserver(function() {
  635. if (showFlag()) {
  636. init();
  637. mutation.disconnect();
  638. }
  639. })
  640. mutation.observe(document.body, {
  641. childList: true,
  642. characterData: true,
  643. subtree: true,
  644. })
  645. }
  646. });
  647.  
  648. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址