diff --git a/src/@core/utils/index.ts b/src/@core/utils/index.ts index 77273059..f6f8226d 100644 --- a/src/@core/utils/index.ts +++ b/src/@core/utils/index.ts @@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string { const res = reg.exec(url) return res ? res[1] : '' } + +// 导出 navigator 相关函数 +export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator' diff --git a/src/@core/utils/navigator.ts b/src/@core/utils/navigator.ts index 1113e0fe..d2bb3fb6 100644 --- a/src/@core/utils/navigator.ts +++ b/src/@core/utils/navigator.ts @@ -84,3 +84,15 @@ export const isMobileDevice = (): boolean => { return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize } + +// 检测是否为iOS设备 +export const isIOSDevice = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase() + return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream +} + +// 检测是否为Android设备 +export const isAndroidDevice = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase() + return /android/.test(userAgent) +} diff --git a/src/components/cards/BackdropCard.vue b/src/components/cards/BackdropCard.vue index ba62b633..822831ee 100644 --- a/src/components/cards/BackdropCard.vue +++ b/src/components/cards/BackdropCard.vue @@ -1,5 +1,6 @@ diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index e3145981..b5082f13 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -740,6 +740,8 @@ export default { searchResource: 'Search Resource', subscribe: 'Subscribe', playOnline: 'Play Online', + playInApp: 'Play in App', + playInWeb: 'Play in Web', }, search: { byTitle: 'Title', @@ -768,6 +770,14 @@ export default { title: 'Error!', noMediaInfo: 'No media information recognized.', }, + server: { + plex: 'Plex', + jellyfin: 'Jellyfin', + emby: 'Emby', + appLaunchFailed: 'App launch failed, redirecting to web version', + appNotInstalled: 'App not detected, redirecting to web version', + downloadApp: 'Download App', + }, }, subscribe: { normalSub: 'Subscribe', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index aa555b0e..991d6602 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -737,6 +737,8 @@ export default { searchResource: '搜索资源', subscribe: '订阅', playOnline: '在线播放', + playInApp: 'APP播放', + playInWeb: '网页播放', }, search: { byTitle: '标题', @@ -765,6 +767,14 @@ export default { title: '出错啦!', noMediaInfo: '未识别到媒体信息。', }, + server: { + plex: 'Plex', + jellyfin: 'Jellyfin', + emby: 'Emby', + appLaunchFailed: 'APP启动失败,正在跳转到网页版', + appNotInstalled: '未检测到APP,正在跳转到网页版', + downloadApp: '下载APP', + }, }, subscribe: { normalSub: '订阅', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index e7241fac..ac2ef7cf 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -735,6 +735,8 @@ export default { searchResource: '搜索資源', subscribe: '訂閱', playOnline: '線上播放', + playInApp: 'APP播放', + playInWeb: '網頁播放', }, search: { byTitle: '標題', @@ -763,6 +765,14 @@ export default { title: '出錯啦!', noMediaInfo: '未識別到媒體信息。', }, + server: { + plex: 'Plex', + jellyfin: 'Jellyfin', + emby: 'Emby', + appLaunchFailed: 'APP啟動失敗,正在跳轉到網頁版', + appNotInstalled: '未檢測到APP,正在跳轉到網頁版', + downloadApp: '下載APP', + }, }, subscribe: { normalSub: '訂閱', diff --git a/src/utils/mediaServerDeepLink.ts b/src/utils/mediaServerDeepLink.ts new file mode 100644 index 00000000..cbc05a5c --- /dev/null +++ b/src/utils/mediaServerDeepLink.ts @@ -0,0 +1,484 @@ +/** + * 媒体服务器深度链接工具 + * 支持Plex、Jellyfin、Emby的APP跳转和网页跳转 + * + * 深度链接格式参考: + * - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456 + * - Emby: https://emby.media/support/articles/Deep-Linking.html + * - Jellyfin: https://jellyfin.org/docs/general/administration/deep-linking + */ + +import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils' + +// 媒体服务器类型 +export type MediaServerType = 'plex' | 'jellyfin' | 'emby' + +// 深度链接配置 +interface DeepLinkConfig { + appScheme: string + webUrl: string + timeout: number +} + +// 各媒体服务器的深度链接配置 +const DEEP_LINK_CONFIGS: Record = { + plex: { + appScheme: 'plex://', + webUrl: 'https://app.plex.tv', + timeout: 2000, + }, + jellyfin: { + appScheme: 'jellyfin://', + webUrl: 'https://jellyfin.org', + timeout: 2000, + }, + emby: { + appScheme: 'emby://', + webUrl: 'https://emby.media', + timeout: 2000, + }, +} + +/** + * 尝试跳转到APP,如果失败则跳转到网页 + * @param serverType 媒体服务器类型 + * @param playUrl 播放链接 + * @param fallbackUrl 备用网页链接 + */ +export async function openMediaServerApp( + serverType: MediaServerType, + playUrl: string, + fallbackUrl?: string, +): Promise { + // 如果不是移动设备,直接使用网页链接 + if (!isMobileDevice()) { + window.open(fallbackUrl || playUrl, '_blank') + return + } + + const config = DEEP_LINK_CONFIGS[serverType] + if (!config) { + console.warn(`不支持的媒体服务器类型: ${serverType}`) + window.open(fallbackUrl || playUrl, '_blank') + return + } + + // 构建APP深度链接 + const appUrl = buildDeepLinkUrl(serverType, playUrl) + + console.log(`构建${serverType}深度链接:`, { + originalUrl: playUrl, + deepLinkUrl: appUrl, + }) + + // 尝试跳转到APP + try { + await attemptAppLaunch(appUrl, config.timeout) + } catch (error) { + console.log(`APP跳转失败,使用网页链接: ${error}`) + // APP跳转失败,使用网页链接 + window.open(fallbackUrl || playUrl, '_blank') + } +} + +/** + * 构建深度链接URL + * @param serverType 媒体服务器类型 + * @param playUrl 播放链接 + */ +function buildDeepLinkUrl(serverType: MediaServerType, playUrl: string): string { + switch (serverType) { + case 'plex': + return buildPlexDeepLink(playUrl) + + case 'jellyfin': + return buildJellyfinDeepLink(playUrl) + + case 'emby': + return buildEmbyDeepLink(playUrl) + + default: + return playUrl + } +} + +/** + * 构建Plex深度链接 + * 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456 + * @param playUrl 播放链接 + */ +function buildPlexDeepLink(playUrl: string): string { + try { + const url = new URL(playUrl) + + // 提取媒体ID和机器标识符 + let mediaId: string | null = null + let machineIdentifier: string | null = null + let libraryKey: string | null = null + + // 格式1: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key} + const mediaLibraryMatch = playUrl.match(/\/media\/([^\/]+)\/com\.plexapp\.plugins\.library\?source=([^&]+)/) + if (mediaLibraryMatch) { + machineIdentifier = mediaLibraryMatch[1] + libraryKey = mediaLibraryMatch[2] + // 对于库链接,我们使用库的key作为媒体ID + mediaId = libraryKey + } + + // 格式2: web/index.html#!/server/{machineIdentifier}/details?key={item_id} + const serverDetailsMatch = playUrl.match(/\/server\/([^\/]+)\/details\?key=([^&]+)/) + if (serverDetailsMatch) { + machineIdentifier = serverDetailsMatch[1] + mediaId = serverDetailsMatch[2] + } + + // 格式3: plex.tv/tv/xxx 或 plex.tv/movie/xxx (传统格式) + if (!mediaId) { + const plexMatch = playUrl.match(/plex\.tv\/(tv|movie)\/([^\/\?]+)/) + if (plexMatch) { + mediaId = plexMatch[2] + } + } + + // 格式4: /media/plex.tv/tv/xxx (传统格式) + if (!mediaId) { + const mediaMatch = playUrl.match(/\/media\/plex\.tv\/(tv|movie)\/([^\/\?]+)/) + if (mediaMatch) { + mediaId = mediaMatch[2] + } + } + + // 如果还没有提取到机器标识符,尝试从其他路径中提取 + if (!machineIdentifier) { + const mediaPathMatch = playUrl.match(/\/media\/([^\/]+)/) + if (mediaPathMatch) { + machineIdentifier = mediaPathMatch[1] + } + } + + if (mediaId) { + // Plex深度链接格式: plex://{媒体ID} + const deepLink = `plex://${mediaId}` + console.log('Plex深度链接构建成功:', { + originalUrl: playUrl, + machineIdentifier, + libraryKey, + mediaId, + deepLink, + }) + return deepLink + } + + // 如果无法提取媒体ID,尝试使用机器标识符 + if (machineIdentifier) { + const fallbackLink = `plex://${machineIdentifier}` + console.log('Plex深度链接构建失败,使用机器标识符:', { + originalUrl: playUrl, + machineIdentifier, + fallbackLink, + }) + return fallbackLink + } + + // 最后的降级方案 + console.log('Plex深度链接构建失败,使用原始URL:', { + originalUrl: playUrl, + }) + return `plex://${playUrl}` + } catch (error) { + console.warn('构建Plex深度链接失败:', error) + return `plex://${playUrl}` + } +} + +/** + * 构建Jellyfin深度链接 + * 参考: https://jellyfin.org/docs/general/administration/deep-linking + * @param playUrl 播放链接 + */ +function buildJellyfinDeepLink(playUrl: string): string { + try { + const url = new URL(playUrl) + const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') + + // 提取媒体ID、库ID、serverId + let mediaId: string | null = null + let libraryId: string | null = null + let serverId: string | null = null + + // 格式1: /details?id={item_id}&serverId={serverid} + const detailsMatch = playUrl.match(/\/details\?id=([^&]+)&serverId=([^&]+)/) + if (detailsMatch) { + mediaId = detailsMatch[1] + serverId = detailsMatch[2] + } + + // 格式2: /movies.html?topParentId={libraryId} + const moviesMatch = playUrl.match(/\/movies\.html\?topParentId=([^&]+)/) + if (moviesMatch) { + libraryId = moviesMatch[1] + } + // 格式3: /tv.html?topParentId={libraryId} + const tvMatch = playUrl.match(/\/tv\.html\?topParentId=([^&]+)/) + if (tvMatch) { + libraryId = tvMatch[1] + } + // 格式4: /library.html?topParentId={libraryId} + const libMatch = playUrl.match(/\/library\.html\?topParentId=([^&]+)/) + if (libMatch) { + libraryId = libMatch[1] + } + + // 兼容原有格式:?id=xxx + if (!mediaId) { + const idMatch = playUrl.match(/[?&]id=([^&]+)/) + if (idMatch) { + mediaId = idMatch[1] + } + } + + // 兼容原有格式:/items/xxx + if (!mediaId) { + const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/) + if (itemsMatch) { + mediaId = itemsMatch[1] + } + } + + // 构建深度链接 + if (mediaId) { + let deepLink = `jellyfin://${serverAddress}/item/${mediaId}` + if (serverId) { + deepLink += `?serverId=${serverId}` + } + console.log('Jellyfin深度链接构建成功:', { + originalUrl: playUrl, + serverAddress, + mediaId, + serverId, + deepLink, + }) + return deepLink + } + if (libraryId) { + const deepLink = `jellyfin://${serverAddress}/library/${libraryId}` + console.log('Jellyfin库深度链接构建成功:', { + originalUrl: playUrl, + serverAddress, + libraryId, + deepLink, + }) + return deepLink + } + + // 如果无法提取ID,尝试直接使用服务器地址 + const fallbackLink = `jellyfin://${serverAddress}` + console.log('Jellyfin深度链接构建失败,使用服务器地址:', { + originalUrl: playUrl, + serverAddress, + fallbackLink, + }) + return fallbackLink + } catch (error) { + console.warn('构建Jellyfin深度链接失败:', error) + return `jellyfin://${playUrl}` + } +} + +/** + * 构建Emby深度链接 + * 参考: https://emby.media/support/articles/Deep-Linking.html + * @param playUrl 播放链接 + */ +function buildEmbyDeepLink(playUrl: string): string { + try { + const url = new URL(playUrl) + const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') + + // 尝试多种格式提取媒体ID + let mediaId: string | null = null + let serverId: string | null = null + + // 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式) + const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/) + if (itemHashMatch) { + mediaId = itemHashMatch[1] + // 提取serverId + const serverIdMatch = playUrl.match(/serverId=([^&]+)/) + if (serverIdMatch) { + serverId = serverIdMatch[1] + } + } + + // 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式) + const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) + if (videosHashMatch) { + // 对于videos格式,我们使用parentId作为媒体ID + mediaId = videosHashMatch[2] + serverId = videosHashMatch[1] + } + + // 格式3: ?id=xxx (通用格式) + if (!mediaId) { + const idMatch = playUrl.match(/[?&]id=([^&]+)/) + if (idMatch) { + mediaId = idMatch[1] + } + } + + // 格式4: /itemdetails.html?id=xxx + if (!mediaId) { + const itemMatch = playUrl.match(/\/itemdetails\.html\?id=([^&]+)/) + if (itemMatch) { + mediaId = itemMatch[1] + } + } + + // 格式5: /items/xxx + if (!mediaId) { + const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/) + if (itemsMatch) { + mediaId = itemsMatch[1] + } + } + + // 格式6: /item/xxx (路径格式) + if (!mediaId) { + const itemPathMatch = playUrl.match(/\/item\/([^\/\?]+)/) + if (itemPathMatch) { + mediaId = itemPathMatch[1] + } + } + + if (mediaId) { + // Emby深度链接格式: emby://{服务器地址}/item/{媒体ID} + // 如果有serverId,也包含进去 + let deepLink = `emby://${serverAddress}/item/${mediaId}` + if (serverId) { + deepLink += `?serverId=${serverId}` + } + + console.log('Emby深度链接构建成功:', { + originalUrl: playUrl, + serverAddress, + mediaId, + serverId, + deepLink, + }) + return deepLink + } + + // 如果无法提取媒体ID,尝试直接使用服务器地址 + // 这会打开Emby APP的主界面 + const fallbackLink = `emby://${serverAddress}` + console.log('Emby深度链接构建失败,使用服务器地址:', { + originalUrl: playUrl, + serverAddress, + fallbackLink, + }) + return fallbackLink + } catch (error) { + console.warn('构建Emby深度链接失败:', error) + return playUrl + } +} + +/** + * 尝试启动APP + * @param appUrl APP深度链接 + * @param timeout 超时时间 + */ +async function attemptAppLaunch(appUrl: string, timeout: number): Promise { + return new Promise((resolve, reject) => { + // 创建一个隐藏的iframe来尝试启动APP + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + iframe.src = appUrl + + // 设置超时 + const timeoutId = setTimeout(() => { + document.body.removeChild(iframe) + reject(new Error('APP启动超时')) + }, timeout) + + // 监听页面可见性变化,如果用户切换到APP,说明启动成功 + const handleVisibilityChange = () => { + if (document.hidden) { + clearTimeout(timeoutId) + document.removeEventListener('visibilitychange', handleVisibilityChange) + document.body.removeChild(iframe) + resolve() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + // 添加到页面并尝试启动 + document.body.appendChild(iframe) + + // 对于iOS,还需要尝试window.location + if (isIOSDevice()) { + try { + window.location.href = appUrl + } catch (error) { + console.log('iOS window.location跳转失败:', error) + } + } + }) +} + +/** + * 根据播放链接自动检测媒体服务器类型并跳转 + * @param playUrl 播放链接 + * @param fallbackUrl 备用网页链接 + */ +export async function openMediaServerWithAutoDetect(playUrl: string, fallbackUrl?: string): Promise { + // 从URL中检测媒体服务器类型 + const url = playUrl.toLowerCase() + + let serverType: MediaServerType | null = null + + if (url.includes('plex') || url.includes('plex.tv')) { + serverType = 'plex' + } else if (url.includes('jellyfin')) { + serverType = 'jellyfin' + } else if (url.includes('emby')) { + serverType = 'emby' + } + + if (serverType) { + await openMediaServerApp(serverType, playUrl, fallbackUrl) + } else { + // 无法检测到服务器类型,直接使用网页链接 + window.open(fallbackUrl || playUrl, '_blank') + } +} + +/** + * 获取媒体服务器的APP下载链接 + * @param serverType 媒体服务器类型 + */ +export function getAppDownloadUrl(serverType: MediaServerType): string { + switch (serverType) { + case 'plex': + return 'https://www.plex.tv/apps/' + case 'jellyfin': + return 'https://jellyfin.org/downloads/' + case 'emby': + return 'https://emby.media/download.html' + default: + return '' + } +} + +/** + * 检查是否安装了特定的媒体服务器APP + * 注意:由于浏览器安全限制,无法直接检测APP是否安装 + * 这个方法主要用于提示用户 + */ +export function checkAppInstalled(serverType: MediaServerType): boolean { + // 由于浏览器安全限制,无法直接检测APP是否安装 + // 这里可以根据用户代理或其他信息进行推测 + // 目前返回false,让系统总是尝试跳转 + return false +} diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue index 81aa2c31..524986db 100644 --- a/src/views/discover/MediaDetailView.vue +++ b/src/views/discover/MediaDetailView.vue @@ -16,6 +16,7 @@ import { useTheme } from 'vuetify' import { useI18n } from 'vue-i18n' import { hasPermission } from '@/utils/permission' import { useGlobalSettingsStore } from '@/stores' +import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink' // 国际化 const { t } = useI18n() @@ -475,10 +476,8 @@ async function handlePlay() { try { const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`) if (result?.success) { - // 打开链接地址 - setTimeout(() => { - window.open(result.data.url, '_blank') - }, 100) + // 使用深度链接工具,优先跳转到APP,失败后跳转到网页 + await openMediaServerWithAutoDetect(result.data.url) } else { $toast.error(`获取播放链接失败:${result.message}!`) }