embyLaunchPotplayer

emby/jellfin launch extetnal player

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

QingJ © 2025

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