Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 32 additions & 33 deletions src/apps/legacy/controllers/itemDetails/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ function getSelectedMediaSource(page, mediaSources) {
return mediaSources.filter(m => m.Id === mediaSourceId)[0];
}

// The resume position follows the selected version. Each version is its own item, and
// refreshSelectedVersion swaps currentItem to the selected version's DTO, so its own user data
// already carries the correct per-version position - no per-source field is needed.
function getResumePositionTicks(item) {
return item.UserData ? (item.UserData.PlaybackPositionTicks || 0) : 0;
}

function renderSeriesTimerSchedule(page, apiClient, seriesTimerId) {
apiClient.getLiveTvTimers({
UserId: apiClient.getCurrentUserId(),
Expand Down Expand Up @@ -214,7 +221,7 @@ function renderTrackSelections(page, instance, item, forceReload) {

const currentValue = select.value;

const selectedId = mediaSources[0].Id;
const selectedId = mediaSources.some(m => m.Id === currentValue) ? currentValue : mediaSources[0].Id;
select.innerHTML = mediaSources.map(function (v) {
const selected = v.Id === selectedId ? ' selected' : '';
return '<option value="' + v.Id + '"' + selected + '>' + escapeHtml(v.Name) + '</option>';
Expand Down Expand Up @@ -349,7 +356,9 @@ function reloadPlayButtons(page, item) {
hideAll(page, 'btnShuffle', enableShuffle);
canPlay = true;

const isResumable = item.UserData && item.UserData.PlaybackPositionTicks > 0;
// Resume state follows the selected version: currentItem is the selected version's DTO,
// so its own user data determines whether it is resumable.
const isResumable = getResumePositionTicks(item) > 0;
hideAll(page, 'btnReplay', isResumable);

for (const btnPlay of page.querySelectorAll('.btnPlay')) {
Expand Down Expand Up @@ -1965,7 +1974,10 @@ export default function (view, params) {
return;
}

playItem(item, item.UserData && mode === ItemAction.Resume ? item.UserData.PlaybackPositionTicks : 0);
// Resume from the position of the version that is about to play (currentItem is the selected version).
const startPositionTicks = mode === ItemAction.Resume ? getResumePositionTicks(item) : 0;

playItem(item, startPositionTicks);
}

function onPlayClick() {
Expand Down Expand Up @@ -2065,8 +2077,9 @@ export default function (view, params) {

if (!currentItem || Data?.UserId != apiClient.getCurrentUserId()) return;

const key = currentItem.UserData.Key;
const userData = (Data?.UserDataList ?? []).find(u => u.Key == key);
// Match by item id: alternate versions share the same user data key, so a key match
// would apply another version's progress to the displayed item.
const userData = (Data?.UserDataList ?? []).find(u => u.ItemId == currentItem.Id);

if (userData) {
currentItem.UserData = userData;
Expand Down Expand Up @@ -2100,7 +2113,6 @@ export default function (view, params) {
renderVideoSelections(view, self._currentPlaybackMediaSources);
renderAudioSelections(view, self._currentPlaybackMediaSources);
renderSubtitleSelections(view, self._currentPlaybackMediaSources);
updateMiscInfo();
refreshSelectedVersion();
});
view.addEventListener('viewshow', function (e) {
Expand All @@ -2113,6 +2125,7 @@ export default function (view, params) {
libraryMenu.setTitle('');
renderTrackSelections(page, self, currentItem, true);
renderBackdrop(page, currentItem);
refreshSelectedVersion();
}
} else {
reload(self, page, params);
Expand Down Expand Up @@ -2142,18 +2155,6 @@ export default function (view, params) {
});
}

function updateMiscInfo() {
const selectedMediaSource = getSelectedMediaSource(view, self._currentPlaybackMediaSources);
renderMiscInfo(view, {
// patch currentItem (primary item) with details from the selected MediaSource:
...currentItem,
...selectedMediaSource
});
}

// When the user picks an alternate version, fetch that Video item's own DTO
// and refresh the play/user-data buttons so resume position, watched state, and
// stack-part count reflect the selected version rather than the primary's.
function refreshSelectedVersion() {
const selectedId = view.querySelector('.selectSource').value;
if (!selectedId || !currentItem || selectedId === currentItem.Id) {
Expand All @@ -2169,24 +2170,22 @@ export default function (view, params) {
return;
}

getLibraryApi(api).getItem({
userId: apiClient?.getCurrentUserId(),
itemId: selectedId
}).then(function ({ data: altItem }) {
Promise.all([
getLibraryApi(api).getItem({
userId: apiClient?.getCurrentUserId(),
itemId: selectedId
}),
apiClient.getCurrentUser()
]).then(function ([{ data: versionItem }, user]) {
if (view.querySelector('.selectSource').value !== selectedId) {
return;
}
// Keep primary's shared metadata (overview, cast, etc.) and override the
// fields that are intrinsic to the playback target (UserData, runtime, parts).
const merged = {
...currentItem,
UserData: altItem.UserData,
RunTimeTicks: altItem.RunTimeTicks,
PartCount: altItem.PartCount,
MediaStreams: altItem.MediaStreams
};
reloadPlayButtons(view, merged);
reloadUserDataButtons(view, merged);

currentItem = versionItem;
reloadFromItem(self, view, params, versionItem, user);
renderVideoSelections(view, self._currentPlaybackMediaSources);
renderAudioSelections(view, self._currentPlaybackMediaSources);
renderSubtitleSelections(view, self._currentPlaybackMediaSources);
}).catch(function (err) {
console.error('failed to load alternate version item', err);
});
Expand Down
83 changes: 77 additions & 6 deletions src/components/playback/playbackmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,18 @@
for (let i = 0, length = versions.length; i < length; i++) {
versions[i].enableDirectPlay = results[i] || false;
}

// The played item's own media source carries its resume state, so it must stay selected as
// long as it is playable at all - an unplayable codec means transcoding it, not silently
// switching to an alternate version.
const ownSource = versions.find(function (v) {
return v.Id === item.Id;
});

if (ownSource && (ownSource.enableDirectPlay || ownSource.SupportsDirectStream || ownSource.SupportsTranscoding)) {
return ownSource;
}

let optimalVersion = versions.filter(function (v) {
return v.enableDirectPlay;
})[0];
Expand Down Expand Up @@ -1741,6 +1753,8 @@

getPlaybackInfo(player, apiClient, currentItem, deviceProfile, currentMediaSource.Id, liveStreamId, options).then(function (result) {
if (validatePlaybackInfoResult(self, result)) {
// Changing streams requests only the active source; keep the version availability flag.
result.MediaSources[0].hasAlternateVersions = currentMediaSource.hasAlternateVersions;
currentMediaSource = result.MediaSources[0];

const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks, player);
Expand Down Expand Up @@ -2069,13 +2083,23 @@
}

function filterEpisodes(episodesResult, firstItem, options) {
let startItemFound = false;
for (const [index, e] of episodesResult.Items.entries()) {
if (e.Id === firstItem.Id) {
episodesResult.StartIndex = index;
startItemFound = true;
break;
}
}

// An alternate version is not part of the episode listing, so the result starts at
// its primary episode instead. Keep playing the version the user picked by selecting
// it as the media source of that primary (unless a source was explicitly chosen).
if (!startItemFound && episodesResult.Items.length) {
episodesResult.StartIndex = 0;
options.mediaSourceId = options.mediaSourceId || firstItem.Id;
}

// TODO: fix calling code to read episodesResult.StartIndex instead when set.
options.startIndex = episodesResult.StartIndex;
episodesResult.TotalRecordCount = episodesResult.Items.length;
Expand Down Expand Up @@ -2953,6 +2977,11 @@
if (validatePlaybackInfoResult(self, playbackInfoResult)) {
return getOptimalMediaSource(apiClient, item, playbackInfoResult.MediaSources).then(function (mediaSource) {
if (mediaSource) {
// Remember whether alternate versions exists
mediaSource.hasAlternateVersions = playbackInfoResult.MediaSources.length > 1
|| item.MediaSources?.length > 1
|| (!!mediaSourceId && mediaSourceId !== item.Id);

if (mediaSource.RequiresOpening && !mediaSource.LiveStreamId) {
options.audioStreamIndex = null;
options.subtitleStreamIndex = null;
Expand Down Expand Up @@ -3127,6 +3156,32 @@
};
}

// Find the id of the version (media source) of an item whose name matches the
// currently playing version, so track navigation keeps the same version across episodes.
function getMatchingMediaSourceId(apiClient, item, prevSource) {

Check warning on line 3161 in src/components/playback/playbackmanager.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function 'getMatchingMediaSourceId' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin-web&issues=AZ6M1QsmIgaVrDXRUhXf&open=AZ6M1QsmIgaVrDXRUhXf&pullRequest=7984
const versionName = prevSource?.Name;
if (!versionName || !prevSource.hasAlternateVersions) {
return Promise.resolve(null);
}

const findMatch = function (mediaSources) {
if (!mediaSources || mediaSources.length < 2) {
return null;
}
const match = mediaSources.find(source => source.Name === versionName);
return match ? match.Id : null;
};

// Queue items usually only carry their primary media source, so the merged alternate versions are missing.
if (item.MediaSources?.length > 1) {
return Promise.resolve(findMatch(item.MediaSources));
}

return apiClient.getItem(apiClient.getCurrentUserId(), item.Id)
Comment thread
Shadowghost marked this conversation as resolved.
.then(fullItem => findMatch(fullItem.MediaSources))
.catch(() => null);
}

self.nextTrack = function (player) {
Comment thread
Shadowghost marked this conversation as resolved.
player = player || self._currentPlayer;
if (player && !enableLocalPlaylistManagement(player)) {
Expand All @@ -3138,11 +3193,19 @@
if (newItemInfo) {
console.debug('playing next track');

const prevSource = getPreviousSource(player);
const newItemPlayOptions = newItemInfo.item.playOptions || getDefaultPlayOptions();
const apiClient = ServerConnections.getApiClient(newItemInfo.item.ServerId);

playInternal(newItemInfo.item, newItemPlayOptions, function () {
setPlaylistState(newItemInfo.item.PlaylistItemId, newItemInfo.index);
}, getPreviousSource(player));
getMatchingMediaSourceId(apiClient, newItemInfo.item, prevSource).then(function (mediaSourceId) {
if (mediaSourceId) {
newItemPlayOptions.mediaSourceId = mediaSourceId;
}

playInternal(newItemInfo.item, newItemPlayOptions, function () {
setPlaylistState(newItemInfo.item.PlaylistItemId, newItemInfo.index);
}, prevSource);
});
}
};

Expand All @@ -3158,12 +3221,20 @@
const newItem = playlist[newIndex];

if (newItem) {
const prevSource = getPreviousSource(player);
const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions();
newItemPlayOptions.startPositionTicks = 0;
const apiClient = ServerConnections.getApiClient(newItem.ServerId);

playInternal(newItem, newItemPlayOptions, function () {
setPlaylistState(newItem.PlaylistItemId, newIndex);
}, getPreviousSource(player));
getMatchingMediaSourceId(apiClient, newItem, prevSource).then(function (mediaSourceId) {
if (mediaSourceId) {
newItemPlayOptions.mediaSourceId = mediaSourceId;
}

playInternal(newItem, newItemPlayOptions, function () {
setPlaylistState(newItem.PlaylistItemId, newIndex);
}, prevSource);
});
}
}
};
Expand Down
Loading