diff --git a/en/index.html b/en/index.html
index 7a5a63e..a60ed56 100644
--- a/en/index.html
+++ b/en/index.html
@@ -593,6 +593,11 @@
init() {
this.cacheDOM();
this.loadConfig();
+
+ // Dynamic app version detection: extract from HTML (e.g. v1.1 -> 1.1)
+ const vEl = document.querySelector('.bg-primary\\/20.text-xs');
+ this.config.appVersion = vEl ? vEl.textContent.replace('v', '').trim() : '1.1';
+
this.loadFavorites();
this.bindEvents();
@@ -649,6 +654,13 @@
this.config.autoplay = localStorage.getItem('emby_autoplay') !== 'false';
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
+ // Unique Device ID for session management
+ if (!localStorage.getItem('emby_device_id')) {
+ const randomId = Math.random().toString(36).slice(2, 10);
+ localStorage.setItem('emby_device_id', `EmbyX-${randomId}`);
+ }
+ this.config.deviceId = localStorage.getItem('emby_device_id');
+
const savedScale = localStorage.getItem('emby_is_scale_fill');
if (savedScale !== null) this.state.isScaleFill = savedScale === 'true';
@@ -695,7 +707,7 @@
this.toggleLoading(true);
try {
- if (pwd === '****' && this.config.token && this.config.server === server && this.config.user === user) {
+ if (/^\*+$/.test(pwd) && this.config.token && this.config.server === server && this.config.user === user) {
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
@@ -719,7 +731,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({ Username: user, Pw: pwd })
});
@@ -745,7 +757,7 @@
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
- this.config = { server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
+ this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
const pwdLen = Math.max(1, pwd.length);
localStorage.setItem('emby_pwd_len', pwdLen);
@@ -846,7 +858,7 @@
const res = await fetch(url, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
if (!res.ok) {
@@ -1359,33 +1371,93 @@
if (!v) return;
const retryCount = parseInt(v.dataset.retryCount || '0');
-
- if (this.config.useStatic) {
- const doShow = (fmt) => this.showToast(`Native player doesn't support codec (${fmt ? fmt.toUpperCase() : 'Unknown'})`);
- const { server, token, userId } = this.config;
- fetch(`${server}/emby/Users/${userId}/Items/${item.Id}?api_key=${token}&Fields=MediaSources`)
- .then(r => r.json())
- .then(d => {
- const src = d.MediaSources?.[0];
- const vStream = src?.MediaStreams?.find(s => s.Type === 'Video');
- const codec = vStream?.Codec || src?.Container || '';
- doShow(codec);
- })
- .catch(() => doShow(''));
- return;
- }
-
- if (retryCount >= 1) {
- this.showToast('Transcode failed. Check Emby.');
- return;
- }
+ const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
v.dataset.retryCount = String(retryCount + 1);
+
+ if (this.config.useStatic) {
+ // -- Direct Play fallback chain --
+ if (isIOS) {
+ // iOS fallback: Direct Play → Direct Stream → HLS Transcode
+ if (retryCount === 0) {
+ // Step 1: Direct Stream (remux only, fastest, no re-encode)
+ // Declare HEVC support to prevent Emby from transcoding during remux
+ const { server, token } = this.config;
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ this.showToast('iOS Direct Stream...');
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
+ v.load();
+ this.playVideo(index);
+ } else if (retryCount === 1) {
+ // Step 2: HLS Transcode (ultimate fallback)
+ this.showToast('iOS Transcode...');
+ v.src = this._buildHlsSrc(item.Id, item);
+ v.load();
+ this.playVideo(index);
+ } else {
+ this.toggleLoading(false);
+ this.showToast('Playback failed. Check Emby settings.');
+ }
+ } else {
+ // Non-iOS fallback: Direct Play → Direct Stream
+ if (retryCount === 0) {
+ const { server, token } = this.config;
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ this.showToast('Trying Direct Stream...');
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
+ v.load();
+ this.playVideo(index);
+ } else {
+ this.toggleLoading(false);
+ this.showToast('Playback failed. Disable Direct Play for transcoding.');
+ }
+ }
+ } else {
+ // -- Transcode mode (HLS) failed, no further fallback --
+ this.toggleLoading(false);
+ this.showToast('Transcode failed. Check Emby settings.');
+ }
+ }
+
+ // -- Universal HLS URL builder (all platforms) --
+ // MediaSourceId and PlaySessionId are required by Emby transcode API; missing them causes 500
+ // To adjust bitrate cap, modify VideoBitrate below (unit: bps, default 20Mbps)
+ //
+ // [Smart Transcode Strategy]
+ // VideoCodec=h264 (declare only h264 support):
+ // → Source is h264? Allow stream copy (remux into HLS segments, no re-encode) → efficient
+ // → Source is hevc/av1? Emby detects incompatibility, transcodes to h264 → hardware accel
+ // AllowVideoStreamCopy=true:
+ // Allows h264 sources to remux directly; prevents wasteful h264→h264 re-encoding
+ // Note: EnableAutoStreamCopy is NOT added — it can bypass HLS segmentation entirely
+ _buildHlsSrc(id, mediaItem) {
const { server, token } = this.config;
- v.src = `${server}/emby/Videos/${item.Id}/stream?api_key=${token}&Container=mp4`;
- v.load();
- this.playVideo(index);
- this.showToast('Trying to transcode...');
+ const mediaSourceId = mediaItem?.MediaSources?.[0]?.Id || '';
+ const playSessionId = this.state.playSessionId || ('hls_' + id + '_' + Date.now());
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+
+ const params = [
+ `api_key=${token}`,
+ mediaSourceId ? `MediaSourceId=${mediaSourceId}` : '',
+ `DeviceId=${deviceId}`,
+ `PlaySessionId=${playSessionId}`,
+ `VideoCodec=h264`, // h264 only: hevc/av1/etc. forced to transcode
+ `AudioCodec=aac,mp3,ac3`,
+ `VideoBitrate=20000000`,
+ `AudioBitrate=320000`,
+ `TranscodingMaxAudioChannels=2`,
+ `SegmentContainer=ts`, // Use ts: fixes h264 source playback failure with mp4 HLS segments
+ `MinSegments=1`,
+ `BreakOnNonKeyFrames=True`,
+ `AllowVideoStreamCopy=true`, // h264 source: remux, no re-encode (saves GPU/CPU)
+ `AllowAudioStreamCopy=true` // aac audio: copy; other formats: transcode to aac
+ // EnableAutoStreamCopy NOT added — avoids Emby bypassing HLS segmentation
+ ].filter(Boolean).join('&');
+
+ return `${server}/emby/Videos/${id}/master.m3u8?${params}`;
}
togglePlay() {
@@ -1486,7 +1558,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify(payload)
}).catch(() => { });
@@ -1499,7 +1571,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({
PlayableMediaTypes: ["Video"],
@@ -1512,39 +1584,47 @@
getVideoSrc(id, mediaItem) {
const { server, token, useStatic } = this.config;
- if (!useStatic) {
- return `${server}/emby/Videos/${id}/stream.mp4?api_key=${token}&VideoCodec=h264,hevc,vp9&AudioCodec=aac,mp3`;
+ // Get metadata (codec and container)
+ const mediaSource = mediaItem?.MediaSources?.[0];
+ const codec = (mediaSource?.VideoCodec || '').toLowerCase();
+ const container = (mediaSource?.Container || '').toLowerCase();
+ const codecTag = (mediaSource?.VideoCodecTag || '').toLowerCase(); // Get tag like hev1/hvc1
+
+ // Smart Decision: For h264+mp4, always prioritize direct play
+ const isNativeCompatible = codec === 'h264' && (container.includes('mp4') || container.includes('m4v'));
+
+ if (!useStatic && !isNativeCompatible) {
+ // Force HLS only if Direct Play is off AND codec is not h264+mp4
+ return this._buildHlsSrc(id, mediaItem);
}
- const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
- (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ // -- Device Compatibility Check for Apple hev1 issue --
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
- if (isIOS && mediaItem) {
- const mediaSourceId = mediaItem.MediaSources?.[0]?.Id || '';
- if (mediaSourceId) {
- const playSessionId = 'ios_hls_' + id + '_' + Date.now();
- const hlsProps = [
- `api_key=${token}`,
- `MediaSourceId=${mediaSourceId}`,
- `DeviceId=EmbyX_Web_iOS`,
- `PlaySessionId=${playSessionId}`,
- `VideoCodec=hevc,h264`,
- `AudioCodec=aac,mp3,ac3`,
- `VideoBitrate=140000000`,
- `AudioBitrate=320000`,
- `TranscodingMaxAudioChannels=2`,
- `SegmentContainer=mp4,ts`,
- `MinSegments=1`,
- `BreakOnNonKeyFrames=True`,
- `EnableAutoStreamCopy=true`,
- `AllowVideoStreamCopy=true`,
- `AllowAudioStreamCopy=true`
- ].join('&');
- return `${server}/emby/Videos/${id}/master.m3u8?${hlsProps}`;
- }
+ // Specific bypass for iOS + HEVC + hev1 tag (Safari only natively supports hvc1)
+ // Direct play (Static) for hev1 fails on iOS. Force Direct Stream (Remux) to fix the tag.
+ if (isIOS && codec === 'hevc' && codecTag === 'hev1') {
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ // Using stream.mp4 with Copy allows Emby to Remux (hev1 -> hvc1) without transcoding.
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ return `${server}/emby/Videos/${id}/stream.mp4?${params}`;
}
- return `${server}/emby/Videos/${id}/stream?api_key=${token}&Static=true`;
+ // -- Direct Play (Static) --
+ const playSessionId = this.state.playSessionId || ('ex_' + Date.now().toString(16));
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ const msId = mediaSource?.Id || '';
+
+ const params = [
+ `api_key=${token}`,
+ `Static=true`,
+ msId ? `MediaSourceId=${msId}` : '',
+ `DeviceId=${deviceId}`,
+ `PlaySessionId=${playSessionId}`
+ ].filter(Boolean).join('&');
+
+ return `${server}/emby/Videos/${id}/stream?${params}`;
}
getImageSrc(video) {
@@ -1572,14 +1652,14 @@
const playlistsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Items?Recursive=true&IncludeItemTypes=Playlist&api_key=${this.config.token}`, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const playlistsData = await playlistsRes.json();
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const viewsDataRaw = viewsRes.ok ? await viewsRes.json() : { Items: [] };
diff --git a/zh/index.html b/zh/index.html
index 27f10da..84add78 100644
--- a/zh/index.html
+++ b/zh/index.html
@@ -626,6 +626,11 @@
init() {
this.cacheDOM();
this.loadConfig();
+
+ // 动态读取版本号:从 HTML 标签中提取(如 v1.1 -> 1.1)
+ const vEl = document.querySelector('.bg-primary\\/20.text-xs');
+ this.config.appVersion = vEl ? vEl.textContent.replace('v', '').trim() : '1.1';
+
this.loadFavorites();
this.bindEvents();
@@ -687,6 +692,13 @@
this.config.autoplay = localStorage.getItem('emby_autoplay') !== 'false';
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
+ // 生成或读取设备唯一 ID,解决多设备识别冲突问题
+ if (!localStorage.getItem('emby_device_id')) {
+ const randomId = Math.random().toString(36).slice(2, 10);
+ localStorage.setItem('emby_device_id', `EmbyX-${randomId}`);
+ }
+ this.config.deviceId = localStorage.getItem('emby_device_id');
+
// 读取高频交互状态
const savedScale = localStorage.getItem('emby_is_scale_fill');
if (savedScale !== null) this.state.isScaleFill = savedScale === 'true';
@@ -738,7 +750,7 @@
this.toggleLoading(true);
try {
// 如果是用旧 token 更新设置,跳过登录
- if (pwd === '****' && this.config.token && this.config.server === server && this.config.user === user) {
+ if (/^\*+$/.test(pwd) && this.config.token && this.config.server === server && this.config.user === user) {
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
@@ -762,7 +774,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({ Username: user, Pw: pwd })
});
@@ -790,7 +802,7 @@
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
- this.config = { server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
+ this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
// 将密码位数存入 localStorage,不存密码本身;空密码至少显示 1 个 *
const pwdLen = Math.max(1, pwd.length);
@@ -901,7 +913,7 @@
const res = await fetch(url, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
if (!res.ok) {
@@ -1463,36 +1475,96 @@
if (!v) return;
const retryCount = parseInt(v.dataset.retryCount || '0');
-
- if (this.config.useStatic) {
- // 报错时立即请求文件详情获取库容器编码
- const doShow = (fmt) => this.showToast(`原生播放器不支持此编码 (${fmt ? fmt.toUpperCase() : '未知'})`);
- const { server, token, userId } = this.config;
- fetch(`${server}/emby/Users/${userId}/Items/${item.Id}?api_key=${token}&Fields=MediaSources`)
- .then(r => r.json())
- .then(d => {
- const src = d.MediaSources?.[0];
- const vStream = src?.MediaStreams?.find(s => s.Type === 'Video');
- const codec = vStream?.Codec || src?.Container || '';
- doShow(codec);
- })
- .catch(() => doShow(''));
- return;
- }
-
- // 如果取消了"直接播放"(允许转码),进行1次重试,使用转码流
- if (retryCount >= 1) {
- this.showToast('转码播放失败,请检查 Emby 设置');
- return;
- }
+ // iOS 识别:Safari/WKWebView,以及 Mac 上 Safari(maxTouchPoints > 1)
+ const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
v.dataset.retryCount = String(retryCount + 1);
+
+ if (this.config.useStatic) {
+ // ── 直接播放模式下的回退链 ────────────────────────────────
+ // 修改回退链步数:iOS 三步 / 非 iOS 两步
+ if (isIOS) {
+ // iOS 降级链:Direct Play → Direct Stream → HLS 转码
+ if (retryCount === 0) {
+ // Step 1: Direct Stream(直接串流:只转封装不重编,速度最快)
+ // 补全 VideoCodec 声明,防止 Emby 误判定为不支持 hevc 而触发重编码
+ const { server, token } = this.config;
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ this.showToast('iOS 直接串流...');
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
+ v.load();
+ this.playVideo(index);
+ } else if (retryCount === 1) {
+ // Step 2: HLS 转码(兼容所有编码的兜底方案)
+ this.showToast('iOS 兼容播放...');
+ v.src = this._buildHlsSrc(item.Id, item);
+ v.load();
+ this.playVideo(index);
+ } else {
+ this.toggleLoading(false);
+ this.showToast('播放失败,请检查 Emby 设置');
+ }
+ } else {
+ // 非 iOS 降级链:Direct Play → Direct Stream
+ if (retryCount === 0) {
+ const { server, token } = this.config;
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ this.showToast('尝试直接串流...');
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
+ v.load();
+ this.playVideo(index);
+ } else {
+ this.toggleLoading(false);
+ this.showToast('播放失败,禁用"直接播放"可转码');
+ }
+ }
+ } else {
+ // ── 转码模式(HLS)失败,无更多降级选项 ───────────────────
+ this.toggleLoading(false);
+ this.showToast('转码失败,请检查 Emby 设置');
+ }
+ }
+
+ // ── HLS 转码 URL 构造器(全平台通用)────────────────────────────────
+ // MediaSourceId、PlaySessionId 是 Emby 转码 API 的必传参数,缺少会导致 500
+ // 修改规格:如需调整码率上限,修改 VideoBitrate(单位 bps,默认 20Mbps)
+ //
+ // 【智能转码策略说明】
+ // VideoCodec=h264(只声明支持 h264):
+ // → 源文件是 h264?允许 stream copy(remux 进 HLS 分片,不重编)→ 高效
+ // → 源文件是 hevc/av1?Emby 判断客户端不支持,自动转码为 h264 → 触发硬件加速
+ // AllowVideoStreamCopy=true:
+ // 明确允许 h264 直接 remux,避免 h264 套娃重编(节省 GPU/CPU)
+ // 注意:EnableAutoStreamCopy 不加,它会绕过 HLS 分片逻辑直接走流传输
+ _buildHlsSrc(id, mediaItem) {
const { server, token } = this.config;
- // 转码模式 Container=mp4
- v.src = `${server}/emby/Videos/${item.Id}/stream?api_key=${token}&Container=mp4`;
- v.load();
- this.playVideo(index);
- this.showToast('尝试转码播放中...');
+ const mediaSourceId = mediaItem?.MediaSources?.[0]?.Id || '';
+ // 每次播放生成唯一会话 ID,保证 Emby 不复用旧的转码任务
+ const playSessionId = this.state.playSessionId || ('hls_' + id + '_' + Date.now());
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+
+ const params = [
+ `api_key=${token}`,
+ mediaSourceId ? `MediaSourceId=${mediaSourceId}` : '',
+ `DeviceId=${deviceId}`,
+ `PlaySessionId=${playSessionId}`,
+ `VideoCodec=h264`, // 只声明 h264:hevc/av1/等会被强制转码为 h264
+ `AudioCodec=aac,mp3,ac3`,
+ `VideoBitrate=20000000`,
+ `AudioBitrate=320000`,
+ `TranscodingMaxAudioChannels=2`,
+ `SegmentContainer=ts`, // 锁定为 ts:解决 h264 源文件在 fMP4/mp4 容器下 HLS 播放失败的问题
+ `MinSegments=1`,
+ `BreakOnNonKeyFrames=True`,
+ `AllowVideoStreamCopy=true`, // h264 源文件:直接 remux,不重编(节省资源)
+ `AllowAudioStreamCopy=true` // aac 音频:直接 copy;其他音频:转为 aac
+ // 不加 EnableAutoStreamCopy:避免 Emby 绕过 HLS 分片直接走流传输
+ ].filter(Boolean).join('&');
+
+ return `${server}/emby/Videos/${id}/master.m3u8?${params}`;
}
togglePlay() {
@@ -1599,7 +1671,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify(payload)
}).catch(() => { });
@@ -1612,7 +1684,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({
PlayableMediaTypes: ["Video"],
@@ -1627,46 +1699,48 @@
getVideoSrc(id, mediaItem) {
const { server, token, useStatic } = this.config;
- // 策略 B:取消【直接播放】,全权委托 Emby 服务器自动降级
- // Direct Play > Direct Stream > Transcode,无需前端干预
- if (!useStatic) {
- return `${server}/emby/Videos/${id}/stream.mp4?api_key=${token}&VideoCodec=h264,hevc,vp9&AudioCodec=aac,mp3`;
+ // 获取视频流元数据(用于智能判定)
+ const mediaSource = mediaItem?.MediaSources?.[0];
+ const codec = (mediaSource?.VideoCodec || '').toLowerCase();
+ const container = (mediaSource?.Container || '').toLowerCase();
+ const codecTag = (mediaSource?.VideoCodecTag || '').toLowerCase(); // 获取编码标签 (如 hev1/hvc1)
+
+ // 智能判定:若是 h264/mp4,则无论是否勾选转码都优先直连,避免冗余 HLS
+ const isNativeCompatible = codec === 'h264' && (container.includes('mp4') || container.includes('m4v'));
+
+ if (!useStatic && !isNativeCompatible) {
+ // 只有在【不勾选直接播放】且【视频非 h264+mp4】时,才走 HLS 强制转码
+ return this._buildHlsSrc(id, mediaItem);
}
- // 策略 A:勾选【直接播放】,默认原画直连,对 iOS 进行转封装兼容
- const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
- (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
-
- if (isIOS && mediaItem) {
- const mediaSourceId = mediaItem.MediaSources?.[0]?.Id || '';
- if (mediaSourceId) {
- // iOS专属 HLS 流水线
- // 修复:Emby API 强制要求 PlaySessionId,且必须指定 SegmentContainer=mp4 支持 fMP4 HEVC 切片
- // 补充了 VideoBitrate 避免 Emby 计算带限报错
- const playSessionId = 'ios_hls_' + id + '_' + Date.now();
- const hlsProps = [
- `api_key=${token}`,
- `MediaSourceId=${mediaSourceId}`,
- `DeviceId=EmbyX_Web_iOS`,
- `PlaySessionId=${playSessionId}`,
- `VideoCodec=hevc,h264`,
- `AudioCodec=aac,mp3,ac3`,
- `VideoBitrate=140000000`,
- `AudioBitrate=320000`,
- `TranscodingMaxAudioChannels=2`,
- `SegmentContainer=mp4,ts`,
- `MinSegments=1`,
- `BreakOnNonKeyFrames=True`,
- `EnableAutoStreamCopy=true`,
- `AllowVideoStreamCopy=true`,
- `AllowAudioStreamCopy=true`
- ].join('&');
- return `${server}/emby/Videos/${id}/master.m3u8?${hlsProps}`;
- }
+ // ── 设备兼容性判定 (苹果生态历史遗留问题处理) ─────────────────────
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+
+ // 特殊拦截:如果是 iOS 且是 hevc 编码且标签是苹果不支持的 hev1 (Safari 仅原生支持 hvc1)
+ // 这种视频直连(Static)必只有声音无画面,主动帮它走“直接串流(Direct Stream)”进行容器修正
+ if (isIOS && codec === 'hevc' && codecTag === 'hev1') {
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ // 使用 stream.mp4 并允许 Copy。Emby 会自动执行 Remux (hev1 -> hvc1),不重编码,画质无损。
+ const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
+ return `${server}/emby/Videos/${id}/stream.mp4?${params}`;
}
- // 默认:原画直连(安卓全格式 / PC 端 / MediaSources 缺失时兜底)
- return `${server}/emby/Videos/${id}/stream?api_key=${token}&Static=true`;
+ // ── 原画直连 (Direct Play) ──────────────────────────────────
+ // 余下所有情况(非苹果设备、或是 hvc1、或是 h264)保持最高优先级直连,支持后端状态追踪
+ const playSessionId = this.state.playSessionId || ('ex_' + Date.now().toString(16));
+ const deviceId = this.config.deviceId || 'EmbyX-Device';
+ const msId = mediaSource?.Id || '';
+
+ const params = [
+ `api_key=${token}`,
+ `Static=true`,
+ msId ? `MediaSourceId=${msId}` : '',
+ `DeviceId=${deviceId}`,
+ `PlaySessionId=${playSessionId}`
+ ].filter(Boolean).join('&');
+
+ return `${server}/emby/Videos/${id}/stream?${params}`;
}
getImageSrc(video) {
@@ -1696,14 +1770,14 @@
const playlistsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Items?Recursive=true&IncludeItemTypes=Playlist&api_key=${this.config.token}`, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const playlistsData = await playlistsRes.json();
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`, {
headers: {
- 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
+ 'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const viewsDataRaw = viewsRes.ok ? await viewsRes.json() : { Items: [] };