#include "detailview.h" #include "../../tokens/AnimationTokens.h" #include "../../utils/fadehelper.h" #include "../../components/detailactionwidget.h" #include "../../components/detailbottominfowidget.h" #include "../../components/detailcontentwidget.h" #include "../../components/detailtagbutton.h" #include "../../components/flowlayout.h" #include "../../components/horizontallistviewgallery.h" #include "../../components/mediaactionmenu.h" #include "../../components/mediasectionwidget.h" #include "../../components/modernmenubutton.h" #include "../../components/moderntoast.h" #include "../../managers/playbackmanager.h" #include "../../managers/thememanager.h" #include "../../utils/mediaitemutils.h" #include "../../utils/detailcacheutils.h" #include "../../utils/mediasourcepreferenceutils.h" #include "../../utils/numberextractor.h" #include "../../utils/playerpreferenceutils.h" #include "../../utils/smoothscrollcontroller.h" #include "overviewdialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { QWidget *createTransparentReserveWidget(QWidget *parent, const char *objectName, int height) { auto *widget = new QWidget(parent); widget->setObjectName(QString::fromLatin1(objectName)); widget->setFixedHeight(height); widget->hide(); return widget; } int reserveHeightFor(QWidget *widget, int fallbackHeight) { if (!widget) return fallbackHeight; const int hintHeight = widget->sizeHint().height(); return qMax(fallbackHeight, hintHeight); } void showReserveWidget(QWidget *reserve, int height = -1) { if (!reserve) return; if (height > 0 && reserve->height() != height) reserve->setFixedHeight(height); reserve->show(); } void hideReserveWidget(QWidget *reserve) { if (reserve) reserve->hide(); } void prepareReservedSection(QWidget *reserve, MediaSectionWidget *section, int height = -1) { showReserveWidget(reserve, height); if (section) section->clear(); } void applyReservedSectionItems(QWidget *reserve, MediaSectionWidget *section, const QList &items) { if (!section) { hideReserveWidget(reserve); return; } if (items.isEmpty()) { section->clear(); hideReserveWidget(reserve); return; } hideReserveWidget(reserve); section->setItems(items); } QString computeSourcesFingerprint(const QList &sources) { if (sources.isEmpty()) return QStringLiteral(""); QStringList parts; parts.reserve(sources.size()); for (const auto &src : sources) { QStringList streamParts; streamParts.reserve(src.mediaStreams.size()); for (const auto &s : src.mediaStreams) { streamParts << QStringLiteral("%1:%2:%3:%4") .arg(s.type, QString::number(s.index), s.codec, s.language); } parts << QStringLiteral("%1#%2#%3") .arg(src.id, QString::number(src.mediaStreams.size()), streamParts.join(QLatin1Char('|'))); } return parts.join(QLatin1Char(';')); } QString computeBottomInfoFingerprint(const MediaItem &item, const QList &sources) { QStringList studioParts; studioParts.reserve(item.studios.size()); for (const auto &s : item.studios) studioParts << s.name; QStringList urlParts; urlParts.reserve(item.externalUrls.size()); for (const auto &u : item.externalUrls) urlParts << (u.name + QLatin1Char('=') + u.url); return QStringLiteral("%1|tags=%2|studios=%3|urls=%4|src=%5") .arg(item.id, item.tags.join(QLatin1Char(',')), studioParts.join(QLatin1Char(',')), urlParts.join(QLatin1Char(',')), computeSourcesFingerprint(sources)); } QString formatDetailReleaseDate(const MediaItem &item) { QString dateText = item.premiereDate.trimmed(); if (!dateText.isEmpty()) { const int timeSeparator = dateText.indexOf(QLatin1Char('T')); if (timeSeparator > 0) dateText = dateText.left(timeSeparator); const int dateLen = dateText.length(); if (dateLen > 0) { const QString dateOnly = dateText.left(qMin(dateLen, 10)); const QDate date = QDate::fromString(dateOnly, Qt::ISODate); if (date.isValid()) return date.toString(QStringLiteral("yyyy-MM-dd")); return dateOnly; } } if (item.productionYear > 0) return QString::number(item.productionYear); return QString(); } QString formatEpisodeTag(const MediaItem &item) { QString tag; if (item.parentIndexNumber >= 0) { tag += QStringLiteral("S%1").arg(item.parentIndexNumber, 2, 10, QLatin1Char('0')); } if (item.indexNumber >= 0) { tag += QStringLiteral("E%1").arg(item.indexNumber, 2, 10, QLatin1Char('0')); } return tag; } MediaItem resolveSeasonPlaybackEpisode(const QList &episodes) { for (const MediaItem &episode : episodes) { if (episode.userData.playbackPositionTicks > 0 && !episode.userData.played && episode.userData.playedPercentage < 99.0) { return episode; } } for (const MediaItem &episode : episodes) { if (!episode.userData.played) return episode; } return episodes.isEmpty() ? MediaItem{} : episodes.first(); } MediaItem withSeedContext(MediaItem cachedItem, const MediaItem &seedItem) { if (seedItem.id.isEmpty() || seedItem.id != cachedItem.id) return cachedItem; if (cachedItem.name.trimmed().isEmpty()) cachedItem.name = seedItem.name; if (cachedItem.type.trimmed().isEmpty()) cachedItem.type = seedItem.type; if (cachedItem.mediaType.trimmed().isEmpty()) cachedItem.mediaType = seedItem.mediaType; if (cachedItem.overview.trimmed().isEmpty()) cachedItem.overview = seedItem.overview; if (cachedItem.genres.isEmpty()) cachedItem.genres = seedItem.genres; if (cachedItem.taglines.isEmpty()) cachedItem.taglines = seedItem.taglines; if (cachedItem.mediaSources.isEmpty()) cachedItem.mediaSources = seedItem.mediaSources; if (cachedItem.images.primaryTag.isEmpty() && cachedItem.images.thumbTag.isEmpty() && cachedItem.images.backdropTag.isEmpty() && cachedItem.images.logoTag.isEmpty()) { cachedItem.images = seedItem.images; } cachedItem.playlistId = seedItem.playlistId; cachedItem.playlistItemId = seedItem.playlistItemId; cachedItem.hasResumeContext = seedItem.hasResumeContext; cachedItem.resumeItemId = seedItem.resumeItemId; cachedItem.resumeUserData = seedItem.resumeUserData; return cachedItem; } bool mediaItemListsEqual(const QList &left, const QList &right) { if (left.size() != right.size()) return false; for (int i = 0; i < left.size(); ++i) { if (DetailCacheUtils::fingerprint(left[i]) != DetailCacheUtils::fingerprint(right[i])) { return false; } } return true; } QCoro::Task mediaItemListsEqualAsync(QList left, QList right) { co_return co_await QtConcurrent::run( [left = std::move(left), right = std::move(right)]() mutable { return mediaItemListsEqual(left, right); }); } QString castFingerprint(const MediaItem &item) { QStringList parts; parts.reserve(item.people.size()); for (const auto &person : item.people) { parts << QStringLiteral("%1:%2:%3:%4") .arg(person.id, person.name, person.type, person.role); } return parts.join(QLatin1Char('|')); } bool episodesBelongToSeason(const QList &episodes, const MediaItem &season) { if (episodes.isEmpty() || season.indexNumber < 0) return true; for (const MediaItem &episode : episodes) { if (episode.parentIndexNumber < 0) continue; if (episode.parentIndexNumber != season.indexNumber) return false; } return true; } bool cacheEntryHasAnySecondaryData( const DetailCacheUtils::DetailCacheEntry &entry) { return entry.hasPlayableItem || entry.hasSeasons || entry.hasSeasonEpisodes || entry.hasSimilarItems || entry.hasCollections || entry.hasAdditionalParts || !entry.item.people.isEmpty(); } struct CachedDetailImages { std::optional poster; std::optional backdrop; std::optional logo; }; void saveDetailCacheAsync(QString serverId, QString userId, MediaItem item, QString fingerprint) { if (serverId.isEmpty() || userId.isEmpty() || item.id.isEmpty()) return; (void)QtConcurrent::run([serverId = std::move(serverId), userId = std::move(userId), item = std::move(item), fingerprint = std::move(fingerprint)]() mutable { QString errorString; const QString itemId = item.id; DetailCacheUtils::DetailCacheEntry entry; const auto existing = DetailCacheUtils::load(serverId, userId, itemId); if (existing.has_value()) entry = *existing; entry.item = item; if (entry.hasPlayableItem && !entry.playableItemFromServer) { entry.hasPlayableItem = false; entry.playableItem = MediaItem{}; } const bool saved = DetailCacheUtils::save(serverId, userId, entry, &errorString); if (saved) { qDebug() << "[DetailView] Detail cache saved" << "itemId=" << itemId << "fingerprint=" << fingerprint.left(12); } else { qWarning() << "[DetailView] Detail cache save failed" << "itemId=" << itemId << "error=" << errorString; } }); } void saveDetailCacheEntryAsync(QString serverId, QString userId, DetailCacheUtils::DetailCacheEntry entry, QString reason) { if (serverId.isEmpty() || userId.isEmpty() || entry.item.id.isEmpty()) return; (void)QtConcurrent::run([serverId = std::move(serverId), userId = std::move(userId), entry = std::move(entry), reason = std::move(reason)]() mutable { QString errorString; const QString itemId = entry.item.id; const auto existing = DetailCacheUtils::load(serverId, userId, itemId); if (existing.has_value()) { if (!entry.hasPlayableItem && existing->hasPlayableItem && existing->playableItemFromServer) { entry.hasPlayableItem = true; entry.playableItem = existing->playableItem; entry.playableItemFromServer = true; } if (!entry.hasSeasons && existing->hasSeasons) { entry.hasSeasons = true; entry.seasons = existing->seasons; } if (!entry.hasSeasonEpisodes && existing->hasSeasonEpisodes) { entry.hasSeasonEpisodes = true; entry.seasonEpisodes = existing->seasonEpisodes; entry.seasonIndex = existing->seasonIndex; entry.seasonId = existing->seasonId; } if (!entry.hasSimilarItems && existing->hasSimilarItems) { entry.hasSimilarItems = true; entry.similarItems = existing->similarItems; } if (!entry.hasCollections && existing->hasCollections) { entry.hasCollections = true; entry.collections = existing->collections; } if (!entry.hasAdditionalParts && existing->hasAdditionalParts) { entry.hasAdditionalParts = true; entry.additionalParts = existing->additionalParts; } } if (entry.hasPlayableItem && !entry.playableItemFromServer) { entry.hasPlayableItem = false; entry.playableItem = MediaItem{}; } const QString itemFp = DetailCacheUtils::fingerprint(entry.item); const QString sectionsFp = DetailCacheUtils::sectionsFingerprint(entry); const bool saved = DetailCacheUtils::save(serverId, userId, entry, &errorString); if (saved) { qDebug() << "[DetailView] Detail cache snapshot saved" << "itemId=" << itemId << "reason=" << reason << "fingerprint=" << itemFp.left(12) << "sections=" << sectionsFp.left(12); } else { qWarning() << "[DetailView] Detail cache snapshot save failed" << "itemId=" << itemId << "reason=" << reason << "error=" << errorString; } }); } void saveDetailImageAsync(QString serverId, QString userId, QString ownerItemId, QString role, QString imageItemId, QString imageType, QString imageTag, int maxWidth, QImage image) { if (serverId.isEmpty() || userId.isEmpty() || ownerItemId.isEmpty() || imageTag.isEmpty() || image.isNull()) return; (void)QtConcurrent::run([serverId = std::move(serverId), userId = std::move(userId), ownerItemId = std::move(ownerItemId), role = std::move(role), imageItemId = std::move(imageItemId), imageType = std::move(imageType), imageTag = std::move(imageTag), maxWidth, image = std::move(image)]() mutable { QString errorString; const bool saved = DetailCacheUtils::saveImage( serverId, userId, ownerItemId, role, imageItemId, imageType, imageTag, maxWidth, image, &errorString); if (!saved) { qWarning() << "[DetailView] Detail image cache save failed" << "itemId=" << ownerItemId << "role=" << role << "error=" << errorString; } }); } } DetailView::DetailView(QEmbyCore *core, QWidget *parent) : BaseView(core, parent) { setAttribute(Qt::WA_StyledBackground, true); setObjectName("detail-view"); setProperty("showGlobalSearch", true); setupUi(); } void DetailView::scrollToTop() { if (m_mainScrollArea && m_mainScrollArea->verticalScrollBar()) { if (m_vScrollController) { m_vScrollController->scrollTo(0, false); } else { m_mainScrollArea->verticalScrollBar()->setValue(0); } } } void DetailView::setupUi() { auto *mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(0, 0, 0, 0); m_mainScrollArea = new QScrollArea(this); m_mainScrollArea->setWidgetResizable(true); m_mainScrollArea->setFrameShape(QFrame::NoFrame); m_mainScrollArea->setObjectName("detail-scrollarea"); m_mainScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_mainScrollArea->setStyleSheet("QScrollArea { background: transparent; }"); m_vScrollController = new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this); m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast); m_contentWidget = new DetailContentWidget(m_mainScrollArea); m_contentWidget->setObjectName("detail-content-container"); auto *contentLayout = new QVBoxLayout(m_contentWidget); contentLayout->setContentsMargins(0, 0, 0, 10); contentLayout->setSpacing(0); QWidget *infoContainer = new QWidget(m_contentWidget); infoContainer->setObjectName("detail-info-container"); infoContainer->setStyleSheet( "QWidget#detail-info-container { background: transparent; }"); m_infoLayout = new QGridLayout(infoContainer); m_infoLayout->setContentsMargins(40, 40, 40, 0); m_infoLayout->setHorizontalSpacing(40); m_infoLayout->setVerticalSpacing(0); m_infoLayout->setRowMinimumHeight(0, 110); m_logoLabel = new QLabel(infoContainer); m_logoLabel->setObjectName("detail-logo-label"); m_logoLabel->setAlignment(Qt::AlignRight | Qt::AlignTop); m_infoLayout->addWidget(m_logoLabel, 0, 1, 1, 2, Qt::AlignRight | Qt::AlignTop); m_posterLabel = new QLabel(infoContainer); m_posterLabel->setFixedSize(250, 375); m_posterLabel->setObjectName("detail-poster-label"); m_posterLabel->setAlignment(Qt::AlignCenter); m_posterShadow = new QGraphicsDropShadowEffect(this); m_posterShadow->setBlurRadius(20); m_posterShadow->setColor(QColor(0, 0, 0, 80)); m_posterShadow->setOffset(0, 8); m_posterShadow->setEnabled(false); m_posterLabel->setGraphicsEffect(m_posterShadow); m_infoLayout->addWidget(m_posterLabel, 1, 0, 1, 1, Qt::AlignTop); m_textContainer = new QWidget(infoContainer); m_textContainer->setMaximumWidth(1200); m_textContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); auto *textLayout = new QVBoxLayout(m_textContainer); textLayout->setContentsMargins(0, 0, 0, 0); textLayout->setSpacing(10); textLayout->setAlignment(Qt::AlignTop); m_titleLabel = new QLabel(m_textContainer); m_titleLabel->setObjectName("detail-title"); m_titleLabel->setWordWrap(true); m_metaRowWidget = new QWidget(m_textContainer); m_metaRowWidget->setObjectName("detail-meta-row"); auto *metaLayout = new QHBoxLayout(m_metaRowWidget); metaLayout->setContentsMargins(0, 0, 0, 0); metaLayout->setSpacing(6); m_ratingStarLabel = new QLabel(m_metaRowWidget); m_ratingStarLabel->setFixedWidth(16); m_ratingStarLabel->setAlignment(Qt::AlignCenter); m_ratingStarLabel->setPixmap( QIcon(":/svg/dark/star-filled.svg").pixmap(16, 16)); m_ratingStarLabel->setContentsMargins(0, 2, 0, 0); m_ratingStarLabel->hide(); m_metaLabel = new QLabel(m_metaRowWidget); m_metaLabel->setObjectName("detail-meta"); m_metaLabel->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); m_numberButton = new QPushButton(m_metaRowWidget); m_numberButton->setObjectName("detail-number-chip"); m_numberButton->setCursor(Qt::PointingHandCursor); m_numberButton->setFocusPolicy(Qt::NoFocus); m_numberButton->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed); m_numberButton->hide(); connect(m_numberButton, &QPushButton::clicked, this, &DetailView::copyDisplayedNumber); metaLayout->addWidget(m_ratingStarLabel); metaLayout->addWidget(m_metaLabel, 0, Qt::AlignLeft); metaLayout->addWidget(m_numberButton, 0, Qt::AlignLeft); metaLayout->addStretch(1); m_metaRowWidget->hide(); m_tagsWidget = new QWidget(m_textContainer); m_tagsWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); m_tagsLayout = new FlowLayout(m_tagsWidget, 0, 8, 8); m_actionWidget = new DetailActionWidget(m_textContainer); connect(m_actionWidget, &DetailActionWidget::playRequested, this, [this]() -> QCoro::Task { co_await executePlay(m_currentPlayableItem, 0); }); connect(m_actionWidget, &DetailActionWidget::resumeRequested, this, [this]() -> QCoro::Task { co_await executePlay( m_currentPlayableItem, m_currentPlayableItem.userData.playbackPositionTicks); }); connect(m_actionWidget, &DetailActionWidget::favoriteRequested, this, [this]() { if (!m_currentMediaItem.id.isEmpty()) handleFavoriteRequested(m_currentMediaItem); }); connect(m_actionWidget, &DetailActionWidget::playedToggleRequested, this, [this]() { const MediaItem &target = m_currentPlayableItem.type == "Episode" ? m_currentPlayableItem : m_currentMediaItem; if (target.id.isEmpty()) return; if (target.userData.played) handleMarkUnplayedRequested(target); else handleMarkPlayedRequested(target); }); connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() { const MediaItem &target = m_currentPlayableItem.type == "Episode" ? m_currentPlayableItem : m_currentMediaItem; if (!target.id.isEmpty()) m_actionWidget->setPlayedState(target.userData.played); }); connect(m_actionWidget, &DetailActionWidget::sourceVersionChanged, this, &DetailView::onVersionChanged); connect(m_actionWidget, &DetailActionWidget::externalPlayRequested, this, [this](const QString &playerPath) -> QCoro::Task { co_await executeExternalPlay(m_currentPlayableItem, playerPath); }); m_taglineLabel = new QLabel(m_textContainer); m_taglineLabel->setObjectName("detail-tagline"); m_overviewLabel = new QLabel(m_textContainer); m_overviewLabel->setObjectName("detail-overview"); m_overviewLabel->setWordWrap(true); m_overviewLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); m_overviewLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); m_overviewLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); connect(m_overviewLabel, &QLabel::linkActivated, this, &DetailView::onOverviewMoreClicked); textLayout->addWidget(m_titleLabel); textLayout->addWidget(m_metaRowWidget); textLayout->addWidget(m_tagsWidget); textLayout->addWidget(m_actionWidget); textLayout->addSpacing(4); textLayout->addWidget(m_taglineLabel); textLayout->addWidget(m_overviewLabel); textLayout->addSpacing(10); m_infoLayout->addWidget(m_textContainer, 1, 1, 1, 1, Qt::AlignTop); m_infoLayout->setColumnStretch(0, 0); m_infoLayout->setColumnStretch(1, 10); m_infoLayout->setColumnStretch(2, 1); contentLayout->addWidget(infoContainer); m_seasonWidget = new MediaSectionWidget(tr("Seasons"), m_core, m_contentWidget); m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); m_seasonWidget->setGalleryHeight(300); m_episodeWidget = new MediaSectionWidget(tr("Episodes"), m_core, m_contentWidget); m_episodeWidget->setCardStyle(MediaCardDelegate::LibraryTile); m_episodeWidget->gallery()->setHoverControls( MediaCardDelegate::HoverControlPlay | MediaCardDelegate::HoverControlMore); { int imgH = 100; int imgW = qRound(imgH * 16.0 / 9.0); const QSize episodeTileSize(imgW + 16, 8 + imgH + 6 + 20); m_episodeTileWidth = episodeTileSize.width(); m_episodeWidget->setTileSize(episodeTileSize); } m_episodeWidget->setGalleryHeight(145); m_episodeHeaderControls = new QWidget(m_episodeWidget); m_episodeHeaderControls->setObjectName("detail-episode-header-controls"); m_episodeHeaderControls->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); auto *episodeHeaderLayout = new QHBoxLayout(m_episodeHeaderControls); episodeHeaderLayout->setContentsMargins(0, 0, 0, 0); episodeHeaderLayout->setSpacing(6); episodeHeaderLayout->setSizeConstraint(QLayout::SetFixedSize); m_seasonSwitcher = new ModernMenuButton(m_episodeHeaderControls); m_seasonSwitcher->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); m_seasonSwitcher->hide(); m_episodeJumpEdit = new QLineEdit(m_episodeHeaderControls); m_episodeJumpEdit->setObjectName("detail-episode-jump-edit"); m_episodeJumpEdit->setPlaceholderText(tr("Ep #")); m_episodeJumpEdit->setToolTip(tr("Jump to episode number")); m_episodeJumpEdit->setAlignment(Qt::AlignCenter); m_episodeJumpEdit->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); m_episodeJumpEdit->setFixedWidth(64); m_episodeJumpEdit->setFixedHeight(m_seasonSwitcher->sizeHint().height()); m_episodeJumpEdit->setMaxLength(4); m_episodeJumpValidator = new QIntValidator(1, 9999, m_episodeJumpEdit); m_episodeJumpEdit->setValidator(m_episodeJumpValidator); m_episodeJumpEdit->hide(); episodeHeaderLayout->addWidget(m_seasonSwitcher, 0, Qt::AlignVCenter); episodeHeaderLayout->addWidget(m_episodeJumpEdit, 0, Qt::AlignVCenter); m_episodeHeaderControls->hide(); m_episodeWidget->setHeaderWidget(m_episodeHeaderControls); m_episodeHeaderControls->hide(); m_episodeSectionReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-episode-section-reserve", reserveHeightFor(m_episodeWidget, 190)); m_seasonSectionReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-season-section-reserve", reserveHeightFor(m_seasonWidget, 320)); connect(m_seasonSwitcher, &ModernMenuButton::currentIndexChanged, this, [this](int idx) { if (idx >= 0 && idx != m_currentSeasonIndex) { markManualSeriesSelection(QStringLiteral("season-switcher")); m_currentSeasonIndex = idx; QCoro::connect(switchToSeason(idx, true), this, []() {}); } }); connect(m_episodeJumpEdit, &QLineEdit::editingFinished, this, &DetailView::submitEpisodeJump); m_castWidget = new MediaSectionWidget(tr("Cast & Crew"), m_core, m_contentWidget); m_castWidget->setCardStyle(MediaCardDelegate::Cast); m_castWidget->setGalleryHeight(280); m_castSectionReserveWidget = new QWidget(m_contentWidget); m_castSectionReserveWidget->setObjectName("detail-cast-section-reserve"); m_castSectionReserveWidget->setFixedHeight( qMax(320, m_castWidget->sizeHint().height())); m_castSectionReserveWidget->hide(); m_additionalPartsWidget = new MediaSectionWidget(tr("Additional Parts"), m_core, m_contentWidget); m_additionalPartsWidget->setCardStyle(MediaCardDelegate::Poster); m_additionalPartsWidget->setGalleryHeight(300); m_additionalPartsSectionReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-additional-section-reserve", reserveHeightFor(m_additionalPartsWidget, 350)); m_collectionWidget = new MediaSectionWidget(tr("Collection"), m_core, m_contentWidget); m_collectionWidget->setCardStyle(MediaCardDelegate::Poster); m_collectionWidget->setGalleryHeight(300); m_collectionSectionReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-collection-section-reserve", reserveHeightFor(m_collectionWidget, 350)); m_similarWidget = new MediaSectionWidget(tr("More Like This"), m_core, m_contentWidget); m_similarWidget->setCardStyle(MediaCardDelegate::Poster); m_similarWidget->setGalleryHeight(300); m_similarSectionReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-similar-section-reserve", reserveHeightFor(m_similarWidget, 350)); m_seasonWidget->gallery()->listView()->viewport()->installEventFilter(this); m_episodeWidget->gallery()->listView()->viewport()->installEventFilter(this); m_castWidget->gallery()->listView()->viewport()->installEventFilter(this); m_additionalPartsWidget->gallery() ->listView() ->viewport() ->installEventFilter(this); m_collectionWidget->gallery()->listView()->viewport()->installEventFilter( this); m_similarWidget->gallery()->listView()->viewport()->installEventFilter(this); auto connectGallerySignals = [this](MediaSectionWidget *widget) { connect(widget, &MediaSectionWidget::playRequested, this, &BaseView::handlePlayRequested); connect(widget, &MediaSectionWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); connect(widget, &MediaSectionWidget::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); }; connectGallerySignals(m_castWidget); connectGallerySignals(m_additionalPartsWidget); connectGallerySignals(m_collectionWidget); connectGallerySignals(m_similarWidget); connect(m_seasonWidget, &MediaSectionWidget::playRequested, this, [this](MediaItem item) -> QCoro::Task { if (item.type == "Season") { markManualSeriesSelection(QStringLiteral("season-play")); co_await executePlaySeason(item); } else { handlePlayRequested(item); } }); connect(m_seasonWidget, &MediaSectionWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); connect(m_seasonWidget, &MediaSectionWidget::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); connect(m_episodeWidget, &MediaSectionWidget::playRequested, this, [this](MediaItem item) -> QCoro::Task { if (item.type == "Episode") { markManualSeriesSelection(QStringLiteral("episode-play")); co_await applySeriesPlayableItem(item, false); if (m_currentPlayableItem.id == item.id) { co_await executePlay( m_currentPlayableItem, m_currentPlayableItem.userData.playbackPositionTicks); } } else { handlePlayRequested(item); } }); connect(m_episodeWidget, &MediaSectionWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); connect(m_episodeWidget, &MediaSectionWidget::moreMenuRequested, this, [this](const MediaItem &item, const QPoint &globalPos) { MediaActionMenu menu(item, m_core, this, {CardContextMenuAction::MarkPlayed, CardContextMenuAction::MarkUnplayed}); CardContextMenuRequest request = menu.execRequest(globalPos); dispatchCardContextMenuRequest(item, request); }); auto commonClicked = [this](const MediaItem &item) { if (item.type == "BoxSet" || item.type == "Playlist" || item.type == "Folder") Q_EMIT navigateToFolder(item.id, item.name); else if (item.type == "Person") Q_EMIT navigateToPerson(item.id, item.name); else Q_EMIT navigateToDetail(item.id, item.name, item); }; connect(m_seasonWidget, &MediaSectionWidget::itemClicked, this, [this](const MediaItem &item) { if (item.type == "Season") Q_EMIT navigateToSeason(m_currentItemId, item.id, item.name); else Q_EMIT navigateToDetail(item.id, item.name, item); }); connect(m_episodeWidget, &MediaSectionWidget::itemClicked, this, [this, commonClicked](const MediaItem &item) { if (item.type == "Episode") { markManualSeriesSelection(QStringLiteral("episode-click")); QCoro::connect(applySeriesPlayableItem(item, true), this, []() {}); } else { commonClicked(item); } }); connect(m_castWidget, &MediaSectionWidget::itemClicked, this, [this](const MediaItem &item) { Q_EMIT navigateToPerson(item.id, item.name); }); connect(m_additionalPartsWidget, &MediaSectionWidget::itemClicked, this, commonClicked); connect(m_collectionWidget, &MediaSectionWidget::itemClicked, this, [this, commonClicked](const MediaItem &item) { if (!item.id.isEmpty()) { qDebug() << "[DetailView] Navigate collection membership" << "itemId=" << item.id << "name=" << item.name << "type=" << item.type << "collectionType=" << item.collectionType; Q_EMIT navigateToFolder(item.id, item.name); return; } commonClicked(item); }); connect(m_similarWidget, &MediaSectionWidget::itemClicked, this, commonClicked); contentLayout->addWidget(m_episodeSectionReserveWidget); contentLayout->addWidget(m_episodeWidget); contentLayout->addWidget(m_seasonSectionReserveWidget); contentLayout->addWidget(m_seasonWidget); contentLayout->addWidget(m_castSectionReserveWidget); contentLayout->addWidget(m_castWidget); contentLayout->addWidget(m_additionalPartsSectionReserveWidget); contentLayout->addWidget(m_additionalPartsWidget); contentLayout->addWidget(m_collectionSectionReserveWidget); contentLayout->addWidget(m_collectionWidget); contentLayout->addWidget(m_similarSectionReserveWidget); contentLayout->addWidget(m_similarWidget); m_bottomInfoWidget = new DetailBottomInfoWidget(m_contentWidget); connect(m_bottomInfoWidget, &DetailBottomInfoWidget::filterClicked, this, [this](const QString &type, const QString &value) { Q_EMIT navigateToFilteredView(type, value); }); m_bottomInfoReserveWidget = createTransparentReserveWidget( m_contentWidget, "detail-bottom-info-reserve", 220); contentLayout->addWidget(m_bottomInfoReserveWidget); contentLayout->addWidget(m_bottomInfoWidget); contentLayout->addStretch(); m_mainScrollArea->setWidget(m_contentWidget); mainLayout->addWidget(m_mainScrollArea); m_mainScrollArea->viewport()->installEventFilter(this); } QCoro::Task DetailView::loadItem(const QString &itemId, const MediaItem &seedItem) { QPointer safeThis(this); setDeferredPresentationBatchActive(false); m_currentItemId = itemId; m_skipNextSilentRefresh = true; m_pendingDeferredFetchId = itemId; m_pendingFetchedItem = MediaItem{}; m_pendingFetchReady = false; m_pendingAnimationGuardDone = false; m_pendingFetchedItemFromCache = false; m_deferBottomInfoReveal = false; m_currentPlayableItem = MediaItem{}; m_currentSeasonEpisodes.clear(); updateEpisodeJumpControl(0); m_appliedSourcesFingerprint.clear(); m_appliedBottomInfoFingerprint.clear(); m_detailCacheServerId.clear(); m_detailCacheUserId.clear(); m_cachedDetailFingerprint.clear(); m_cachedSectionsFingerprint.clear(); m_cachedSeriesPlayableItem = MediaItem{}; m_cachedSeriesSeasons.clear(); m_cachedSeasonEpisodes.clear(); m_cachedSimilarItems.clear(); m_cachedCollections.clear(); m_cachedAdditionalParts.clear(); m_cachedSeasonId.clear(); m_manualSelectedSeasonId.clear(); m_manualSelectedEpisodeId.clear(); m_cachedSeasonIndex = -1; m_manualSelectedSeasonIndex = -1; m_hasManualSeriesSelection = false; m_hasCachedSeriesPlayableItem = false; m_hasCachedSeriesSeasons = false; m_hasCachedSeasonEpisodes = false; m_hasCachedSimilarItems = false; m_hasCachedCollections = false; m_hasCachedAdditionalParts = false; m_hasCachedCastData = false; m_appliedCachedCastToUi = false; m_appliedCachedSeriesSeasonsToUi = false; m_appliedCachedSeasonEpisodesToUi = false; m_appliedCachedSimilarItemsToUi = false; m_appliedCachedCollectionsToUi = false; m_appliedCachedAdditionalPartsToUi = false; m_appliedCastFingerprint.clear(); m_appliedSeriesPlayableFingerprint.clear(); if (m_core && m_core->serverManager()) { const ServerProfile profile = m_core->serverManager()->activeProfile(); m_detailCacheServerId = profile.id; m_detailCacheUserId = profile.userId; } const bool hasSeed = !seedItem.id.isEmpty(); m_deferBottomInfoReveal = hasSeed; if (hasSeed) { m_currentPlayableItem = seedItem; applySeedToUi(seedItem); if (seedItem.type == "Series") { prepareReservedSection(m_episodeSectionReserveWidget, m_episodeWidget, reserveHeightFor(m_episodeWidget, 190)); prepareReservedSection(m_seasonSectionReserveWidget, m_seasonWidget, reserveHeightFor(m_seasonWidget, 320)); } else { hideReserveWidget(m_episodeSectionReserveWidget); hideReserveWidget(m_seasonSectionReserveWidget); m_seasonWidget->clear(); m_episodeWidget->clear(); } } else { m_titleLabel->setText(tr("Loading...")); m_logoLabel->clear(); m_logoLabel->hide(); m_metaLabel->clear(); updateDisplayNumber(QString()); m_metaRowWidget->hide(); m_overviewLabel->clear(); m_taglineLabel->hide(); m_currentBackdropPix = QPixmap(); m_currentPosterPix = QPixmap(); updateBackdrop(); m_posterLabel->setPixmap(QPixmap()); m_actionWidget->clear(); m_bottomInfoWidget->clear(); clearLayout(m_tagsLayout); m_seasonWidget->clear(); m_episodeWidget->clear(); hideReserveWidget(m_episodeSectionReserveWidget); hideReserveWidget(m_seasonSectionReserveWidget); m_seriesSeasons.clear(); m_currentSeasonIndex = 0; if (m_seasonSwitcher) { QSignalBlocker blocker(m_seasonSwitcher); m_seasonSwitcher->clear(); m_seasonSwitcher->hide(); } updateEpisodeHeaderControlsVisibility(); } prepareReservedSection(m_castSectionReserveWidget, m_castWidget, reserveHeightFor(m_castWidget, 320)); prepareReservedSection(m_additionalPartsSectionReserveWidget, m_additionalPartsWidget, reserveHeightFor(m_additionalPartsWidget, 350)); prepareReservedSection(m_collectionSectionReserveWidget, m_collectionWidget, reserveHeightFor(m_collectionWidget, 350)); prepareReservedSection(m_similarSectionReserveWidget, m_similarWidget, reserveHeightFor(m_similarWidget, 350)); scrollToTop(); if (!itemId.isEmpty()) { QCoro::connect(loadDetailCacheAndStartPrefetch( itemId, seedItem, m_detailCacheServerId, m_detailCacheUserId), this, []() {}); } // Guard tracks the page-slide duration (kSlow) so the poster shadow / deferred // content reveal lands as the incoming slide settles; kept in sync via the token. QTimer::singleShot(AnimationTokens::DurationMs::kSlow, this, [safeThis, itemId]() { if (!safeThis || safeThis->m_pendingDeferredFetchId != itemId) return; if (safeThis->m_posterShadow) safeThis->m_posterShadow->setEnabled(true); safeThis->m_pendingAnimationGuardDone = true; safeThis->maybeFlushDeferredUpdate(itemId); }); co_return; } QCoro::Task DetailView::loadDetailCacheAndStartPrefetch(QString itemId, MediaItem seedItem, QString cacheServerId, QString cacheUserId) { QPointer safeThis(this); QString targetId = std::move(itemId); MediaItem seed = std::move(seedItem); QString serverId = std::move(cacheServerId); QString userId = std::move(cacheUserId); QString cachedFingerprint; if (!serverId.isEmpty() && !userId.isEmpty() && !targetId.isEmpty()) { const auto cachedEntry = co_await QtConcurrent::run( [serverId, userId, targetId]() mutable { return DetailCacheUtils::load(serverId, userId, targetId); }); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; if (cachedEntry.has_value()) { MediaItem cachedItem = withSeedContext(cachedEntry->item, seed); cachedFingerprint = cachedEntry->fingerprint; safeThis->m_cachedDetailFingerprint = cachedEntry->fingerprint; safeThis->m_cachedSectionsFingerprint = cachedEntry->sectionsFingerprint; safeThis->m_cachedSeriesPlayableItem = withSeedContext(cachedEntry->playableItem, MediaItem{}); safeThis->m_cachedSeriesSeasons = cachedEntry->seasons; safeThis->m_cachedSeasonEpisodes = cachedEntry->seasonEpisodes; safeThis->m_cachedSimilarItems = cachedEntry->similarItems; safeThis->m_cachedCollections = cachedEntry->collections; safeThis->m_cachedAdditionalParts = cachedEntry->additionalParts; safeThis->m_cachedSeasonId = cachedEntry->seasonId; safeThis->m_cachedSeasonIndex = cachedEntry->seasonIndex; safeThis->m_manualSelectedSeasonId.clear(); safeThis->m_manualSelectedSeasonIndex = -1; safeThis->m_manualSelectedEpisodeId.clear(); safeThis->m_hasCachedSeriesPlayableItem = cachedEntry->hasPlayableItem && cachedEntry->playableItemFromServer; if (!safeThis->m_hasCachedSeriesPlayableItem) safeThis->m_cachedSeriesPlayableItem = MediaItem{}; safeThis->m_hasCachedSeriesSeasons = cachedEntry->hasSeasons; safeThis->m_hasCachedSeasonEpisodes = cachedEntry->hasSeasonEpisodes; safeThis->m_hasCachedSimilarItems = cachedEntry->hasSimilarItems; safeThis->m_hasCachedCollections = cachedEntry->hasCollections; safeThis->m_hasCachedAdditionalParts = cachedEntry->hasAdditionalParts; safeThis->m_hasCachedCastData = !cachedItem.people.isEmpty(); const bool hasCachedSecondaryData = cacheEntryHasAnySecondaryData(*cachedEntry); if (!cachedItem.name.trimmed().isEmpty()) { safeThis->m_pendingFetchedItem = cachedItem; safeThis->m_pendingFetchReady = true; safeThis->m_pendingFetchedItemFromCache = true; const bool hasSeedUi = safeThis->m_currentMediaItem.id == targetId && !safeThis->m_currentMediaItem.name.trimmed().isEmpty(); if (!hasSeedUi) { safeThis->applySeedToUi(cachedItem); } safeThis->maybeFlushDeferredUpdate(targetId); qDebug() << "[DetailView] Detail cache hit" << "itemId=" << targetId << "fingerprint=" << cachedFingerprint.left(12) << "hasSecondaryCache=" << hasCachedSecondaryData << "deferredSeedApply=" << hasSeedUi; } else { cachedFingerprint.clear(); safeThis->m_cachedDetailFingerprint.clear(); safeThis->m_cachedSectionsFingerprint.clear(); safeThis->m_cachedSeriesPlayableItem = MediaItem{}; safeThis->m_cachedSeriesSeasons.clear(); safeThis->m_cachedSeasonEpisodes.clear(); safeThis->m_cachedSimilarItems.clear(); safeThis->m_cachedCollections.clear(); safeThis->m_cachedAdditionalParts.clear(); safeThis->m_cachedSeasonId.clear(); safeThis->m_manualSelectedSeasonId.clear(); safeThis->m_manualSelectedEpisodeId.clear(); safeThis->m_cachedSeasonIndex = -1; safeThis->m_manualSelectedSeasonIndex = -1; safeThis->m_hasCachedSeriesPlayableItem = false; safeThis->m_hasCachedSeriesSeasons = false; safeThis->m_hasCachedSeasonEpisodes = false; safeThis->m_hasCachedSimilarItems = false; safeThis->m_hasCachedCollections = false; safeThis->m_hasCachedAdditionalParts = false; safeThis->m_hasCachedCastData = false; qWarning() << "[DetailView] Ignore incomplete detail cache" << "itemId=" << targetId << "file=" << cachedEntry->filePath; } } else { qDebug() << "[DetailView] Detail cache miss" << "itemId=" << targetId; } } if (!safeThis || safeThis->m_currentItemId != targetId) co_return; if (safeThis->m_core && safeThis->m_core->mediaService()) { QCoro::connect(safeThis->prefetchItemDetail(targetId, serverId, userId, cachedFingerprint), safeThis.data(), []() {}); } } QCoro::Task DetailView::prefetchItemDetail(QString itemId, QString cacheServerId, QString cacheUserId, QString cachedFingerprint) { QPointer safeThis(this); QString targetId = std::move(itemId); QString serverId = std::move(cacheServerId); QString userId = std::move(cacheUserId); QString previousFingerprint = std::move(cachedFingerprint); try { MediaItem item = co_await m_core->mediaService()->getItemDetail(targetId); const QString fetchedFingerprint = co_await QtConcurrent::run( [item]() mutable { return DetailCacheUtils::fingerprint(item); }); const bool differsFromCache = previousFingerprint.isEmpty() || fetchedFingerprint != previousFingerprint; if (differsFromCache) { saveDetailCacheAsync(serverId, userId, item, fetchedFingerprint); } else { qDebug() << "[DetailView] Detail cache unchanged" << "itemId=" << targetId << "fingerprint=" << fetchedFingerprint.left(12); } if (!safeThis || (safeThis->m_pendingDeferredFetchId != targetId && safeThis->m_currentItemId != targetId)) { co_return; } safeThis->m_cachedDetailFingerprint = fetchedFingerprint; if (safeThis->m_pendingDeferredFetchId == targetId) { if (!differsFromCache && safeThis->m_pendingFetchReady && safeThis->m_pendingFetchedItemFromCache) { safeThis->maybeFlushDeferredUpdate(targetId); co_return; } safeThis->m_pendingFetchedItem = std::move(item); safeThis->m_pendingFetchReady = true; safeThis->m_pendingFetchedItemFromCache = false; safeThis->maybeFlushDeferredUpdate(targetId); co_return; } if (safeThis->m_currentItemId == targetId && differsFromCache) { qDebug() << "[DetailView] Applying refreshed detail after cache" << "itemId=" << targetId; QCoro::connect(safeThis->executeDeferredUpdate(std::move(item), true), safeThis.data(), []() {}); } } catch (...) { if (safeThis && safeThis->m_pendingDeferredFetchId == targetId) { if (!safeThis->m_pendingFetchReady && safeThis->m_currentMediaItem.id != targetId) { safeThis->m_pendingDeferredFetchId.clear(); const QString errorText = tr("Error Loading Item"); QMetaObject::invokeMethod( safeThis.data(), [safeThis, targetId, errorText]() { if (safeThis && safeThis->m_currentItemId == targetId) safeThis->m_titleLabel->setText(errorText); }, Qt::QueuedConnection); } else { qDebug() << "[DetailView] Detail refresh failed; using existing data" << "itemId=" << targetId; } } } } void DetailView::maybeFlushDeferredUpdate(const QString &itemId) { if (m_pendingDeferredFetchId != itemId) return; if (!m_pendingFetchReady || !m_pendingAnimationGuardDone) return; m_pendingDeferredFetchId.clear(); MediaItem item = m_pendingFetchedItem; const bool fromCache = m_pendingFetchedItemFromCache; m_pendingFetchedItem = MediaItem{}; m_pendingFetchReady = false; m_pendingAnimationGuardDone = false; m_pendingFetchedItemFromCache = false; if (item.type != "Series" || m_currentPlayableItem.type != "Episode" || m_currentPlayableItem.id.isEmpty()) { m_currentPlayableItem = item; } qDebug() << "[DetailView] Deferred detail update" << "itemId=" << itemId << "fromCache=" << fromCache; QCoro::connect(executeDeferredUpdate(std::move(item)), this, []() {}); } QCoro::Task DetailView::executeDeferredUpdate(MediaItem item, bool isSilentRefresh) { QPointer safeThis(this); const QString targetId = item.id; const QString detectedType = item.type; const bool batchPresentation = isVisible(); if (batchPresentation) { setDeferredPresentationBatchActive(true); QTimer::singleShot(160, this, [safeThis]() { if (safeThis && safeThis->m_deferredPresentationBatchActive) safeThis->setDeferredPresentationBatchActive(false); }); } co_await updateUi(item, isSilentRefresh); if (!safeThis) co_return; safeThis->persistDetailCacheSnapshot(QStringLiteral("detail-ui")); if (isSilentRefresh) { safeThis->setDeferredPresentationBatchActive(false); co_return; } if (batchPresentation) { QTimer::singleShot(0, safeThis.data(), [safeThis, targetId, detectedType]() { if (!safeThis) return; safeThis->finishDeferredPresentationBatch(targetId, detectedType); }); } else { safeThis->startSecondaryFetches(targetId, detectedType); } } void DetailView::setDeferredPresentationBatchActive(bool active) { m_deferredPresentationBatchActive = active; } void DetailView::finishDeferredPresentationBatch( const QString &targetId, const QString &detectedType) { if (m_currentItemId != targetId) { setDeferredPresentationBatchActive(false); return; } updateTagLayoutHeight(); updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); if (m_contentWidget && m_contentWidget->layout()) m_contentWidget->layout()->activate(); if (layout()) layout()->activate(); setDeferredPresentationBatchActive(false); startSecondaryFetches(targetId, detectedType); } void DetailView::startSecondaryFetches(const QString &targetId, const QString &detectedType) { if (targetId.isEmpty() || !m_core || !m_core->mediaService()) return; const auto startNetworkFetches = [this, targetId, detectedType]() { qDebug() << "[DetailView][network] Start secondary fetches" << "itemId=" << targetId << "type=" << detectedType; QCoro::connect( executeFetchSecondaries(QPointer(this), m_core, targetId, detectedType), this, []() {}); }; if (detectedType == "Series") { const bool hasCachedSeriesSecondaryUi = m_hasCachedCastData || m_hasCachedSeriesPlayableItem || m_hasCachedSeriesSeasons || m_hasCachedSeasonEpisodes || m_hasCachedSimilarItems || m_hasCachedCollections || m_hasCachedAdditionalParts; startNetworkFetches(); if (hasCachedSeriesSecondaryUi) { applyCachedSecondaryData(targetId, detectedType); } else { qDebug() << "[DetailView][cache-ui] Skip cached secondary data" << "itemId=" << targetId << "type=" << detectedType << "reason=" << "no-cached-series-secondary-data"; } return; } applyCachedSecondaryData(targetId, detectedType); startNetworkFetches(); } void DetailView::applyCachedSecondaryData(const QString &targetId, const QString &detectedType, bool revealBottomInfo) { if (targetId != m_currentItemId) return; qDebug() << "[DetailView][cache-ui] Applying cached secondary data" << "itemId=" << targetId << "type=" << detectedType << "sections=" << m_cachedSectionsFingerprint.left(12); if (detectedType == "Series") { if (!m_hasManualSeriesSelection && m_hasCachedSeriesPlayableItem && m_cachedSeriesPlayableItem.type == "Episode" && !m_cachedSeriesPlayableItem.id.isEmpty()) { if (m_currentPlayableItem.id != m_cachedSeriesPlayableItem.id) applySeriesPlayableItemToUi(m_cachedSeriesPlayableItem); } if (m_hasCachedSeriesSeasons && !m_appliedCachedSeriesSeasonsToUi) { m_seriesSeasons = m_cachedSeriesSeasons; int targetSeasonIdx = 0; const int cachedEpisodeSeasonIndex = m_cachedSeasonIndex; const QString cachedEpisodeSeasonId = m_cachedSeasonId; bool selectedSeasonResolved = false; if (m_hasManualSeriesSelection && m_currentSeasonIndex >= 0 && m_currentSeasonIndex < m_seriesSeasons.size()) { targetSeasonIdx = m_currentSeasonIndex; selectedSeasonResolved = true; } const int playableParentIdx = m_currentPlayableItem.parentIndexNumber; if (!selectedSeasonResolved && !m_hasManualSeriesSelection && playableParentIdx >= 0) { for (int i = 0; i < m_seriesSeasons.size(); ++i) { if (m_seriesSeasons[i].indexNumber == playableParentIdx) { targetSeasonIdx = i; break; } } } if (!m_seriesSeasons.isEmpty()) { targetSeasonIdx = qBound(0, targetSeasonIdx, m_seriesSeasons.size() - 1); m_currentSeasonIndex = targetSeasonIdx; updateSeasonSwitcher(targetSeasonIdx); } if (m_hasCachedSeasonEpisodes && cachedEpisodeSeasonIndex == targetSeasonIdx && cachedEpisodeSeasonId == (m_seriesSeasons.isEmpty() ? QString() : m_seriesSeasons[targetSeasonIdx].id) && !m_seriesSeasons.isEmpty() && episodesBelongToSeason(m_cachedSeasonEpisodes, m_seriesSeasons[targetSeasonIdx])) { m_currentSeasonEpisodes = m_cachedSeasonEpisodes; if (!m_appliedCachedSeasonEpisodesToUi && m_episodeWidget) { applyReservedSectionItems(m_episodeSectionReserveWidget, m_episodeWidget, m_currentSeasonEpisodes); updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); const QString highlightedEpisodeId = m_currentPlayableItem.id; if (!highlightedEpisodeId.isEmpty()) { m_episodeWidget->gallery()->setHighlightedItemId( highlightedEpisodeId); m_episodeWidget->gallery()->scrollToItemId(highlightedEpisodeId); } } m_appliedCachedSeasonEpisodesToUi = true; } if (m_seasonWidget) { m_seasonWidget->setTitle(tr("Seasons")); m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); m_seasonWidget->setGalleryHeight(300); applyReservedSectionItems(m_seasonSectionReserveWidget, m_seasonWidget, m_seriesSeasons); m_appliedCachedSeriesSeasonsToUi = true; } } } if (m_hasCachedCastData && !m_appliedCachedCastToUi) { applyCastSection(m_currentMediaItem); m_appliedCachedCastToUi = true; } if (revealBottomInfo && m_deferBottomInfoReveal) { m_deferBottomInfoReveal = false; hideReserveWidget(m_bottomInfoReserveWidget); if (m_bottomInfoWidget) m_bottomInfoWidget->show(); } if (m_hasCachedAdditionalParts && !m_appliedCachedAdditionalPartsToUi) { applyReservedSectionItems(m_additionalPartsSectionReserveWidget, m_additionalPartsWidget, m_cachedAdditionalParts); m_appliedCachedAdditionalPartsToUi = true; } else if (!m_hasCachedAdditionalParts && (m_hasCachedCollections || m_hasCachedSimilarItems)) { applyReservedSectionItems(m_additionalPartsSectionReserveWidget, m_additionalPartsWidget, {}); } if (m_hasCachedCollections && !m_appliedCachedCollectionsToUi) { applyCollectionGalleryLayout(m_cachedCollections); applyReservedSectionItems(m_collectionSectionReserveWidget, m_collectionWidget, m_cachedCollections); m_appliedCachedCollectionsToUi = true; } else if (!m_hasCachedCollections && m_hasCachedSimilarItems) { applyReservedSectionItems(m_collectionSectionReserveWidget, m_collectionWidget, {}); } if (m_hasCachedSimilarItems && !m_appliedCachedSimilarItemsToUi) { applyReservedSectionItems(m_similarSectionReserveWidget, m_similarWidget, m_cachedSimilarItems); m_appliedCachedSimilarItemsToUi = true; } } void DetailView::persistDetailCacheSnapshot(const QString &reason) { if (m_detailCacheServerId.isEmpty() || m_detailCacheUserId.isEmpty() || m_currentMediaItem.id.isEmpty()) return; DetailCacheUtils::DetailCacheEntry entry; entry.item = m_currentMediaItem; const bool isSeries = m_currentMediaItem.type == "Series"; entry.hasPlayableItem = isSeries && m_currentPlayableItem.type == "Episode" && !m_currentPlayableItem.id.isEmpty(); if (entry.hasPlayableItem) entry.playableItem = m_currentPlayableItem; entry.hasSeasons = isSeries && m_hasCachedSeriesSeasons; entry.seasons = m_cachedSeriesSeasons; entry.hasSeasonEpisodes = isSeries && m_hasCachedSeasonEpisodes; entry.seasonEpisodes = m_cachedSeasonEpisodes; entry.seasonIndex = isSeries ? m_cachedSeasonIndex : -1; entry.seasonId = isSeries ? m_cachedSeasonId : QString(); entry.playableItemFromServer = entry.hasPlayableItem; entry.hasSimilarItems = m_hasCachedSimilarItems; entry.similarItems = m_cachedSimilarItems; entry.hasCollections = m_hasCachedCollections; entry.collections = m_cachedCollections; entry.hasAdditionalParts = m_hasCachedAdditionalParts; entry.additionalParts = m_cachedAdditionalParts; saveDetailCacheEntryAsync(m_detailCacheServerId, m_detailCacheUserId, entry, reason); } void DetailView::updateMetaRow(const MediaItem &item, const QString &leadingMeta) { QStringList metas; if (item.communityRating > 0) { m_ratingStarLabel->show(); metas << QString::number(item.communityRating, 'f', 1); } else { m_ratingStarLabel->hide(); } if (!leadingMeta.isEmpty()) metas << leadingMeta; const QString releaseDate = formatDetailReleaseDate(item); if (!releaseDate.isEmpty()) metas << releaseDate; if (item.runTimeTicks > 0) metas << formatRunTime(item.runTimeTicks); if (!item.officialRating.isEmpty()) metas << item.officialRating; m_metaLabel->setText(metas.join(" \u2022 ")); m_metaRowWidget->show(); } void DetailView::applySeedToUi(const MediaItem &seed) { m_currentMediaItem = seed; m_titleLabel->setText(seed.name); updateMetaRow(seed, formatEpisodeTag(seed)); const bool shouldShowNumber = shouldShowDisplayNumber(seed); const QString displayNumber = shouldShowNumber ? extractDisplayNumber(seed) : QString(); updateDisplayNumber(displayNumber); QString text = seed.overview; text.replace(QRegularExpression("", QRegularExpression::CaseInsensitiveOption), "\n"); text.replace("

", "\n\n"); text.remove(QRegularExpression("<[^>]*>")); QTextDocument doc; doc.setHtml(text); QString cleanText = doc.toPlainText(); cleanText.replace(QChar::LineSeparator, '\n'); cleanText.remove(QRegularExpression("<[^>]*>")); m_cleanOverviewText = cleanText; m_lastOverviewWidth = -1; updateOverviewElidedText(); if (!seed.taglines.isEmpty()) { m_taglineLabel->setText(seed.taglines.first()); m_taglineLabel->show(); } else { m_taglineLabel->hide(); } m_isFavorite = seed.isFavorite(); m_actionWidget->setFavoriteState(m_isFavorite); m_actionWidget->setPlayedState(seed.userData.played); if (seed.type == "Series") { m_actionWidget->setSeriesLoadingMode(); } else { m_actionWidget->setupNormalMode(seed); } if (!seed.mediaSources.isEmpty()) { m_actionWidget->setSources(seed.mediaSources, 0); if (!seed.mediaSources.first().mediaStreams.isEmpty()) { m_actionWidget->setStreams(seed.mediaSources.first()); } m_appliedSourcesFingerprint = computeSourcesFingerprint(seed.mediaSources); } else { m_actionWidget->setSources(QList{}, 0); m_actionWidget->setStreams(MediaSourceInfo{}); m_appliedSourcesFingerprint = computeSourcesFingerprint(QList{}); } m_actionWidget->refreshExtPlayerButton(); buildTagButtons(seed.genres); QList seedDefaultSource; if (!seed.mediaSources.isEmpty()) seedDefaultSource.append(seed.mediaSources.first()); m_bottomInfoWidget->setInfo(seed, seedDefaultSource); m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); if (m_deferBottomInfoReveal) { showReserveWidget(m_bottomInfoReserveWidget, reserveHeightFor(m_bottomInfoWidget, 220)); } else { hideReserveWidget(m_bottomInfoReserveWidget); } m_appliedBottomInfoFingerprint = computeBottomInfoFingerprint(seed, seedDefaultSource); m_currentBackdropPix = QPixmap(); updateBackdrop(); QCoro::connect(executeLoadImages(QPointer(this), m_core, seed, false), this, []() {}); } void DetailView::buildTagButtons(const QStringList &genres) { if (!m_tagsWidget || !m_tagsLayout) return; if (m_tagsLayout->count() == genres.size()) { bool same = true; for (int i = 0; i < genres.size(); ++i) { QLayoutItem *layoutItem = m_tagsLayout->itemAt(i); QWidget *w = layoutItem ? layoutItem->widget() : nullptr; auto *btn = qobject_cast(w); if (!btn || btn->text() != genres[i]) { same = false; break; } } if (same) return; } m_tagsWidget->setUpdatesEnabled(false); clearLayout(m_tagsLayout); for (const QString &genre : genres) { auto *tagBtn = new DetailTagButton(genre, m_tagsWidget); connect(tagBtn, &QPushButton::clicked, this, [this, genre]() { Q_EMIT navigateToFilteredView("Genre", genre); }); m_tagsLayout->addWidget(tagBtn); } m_tagsWidget->setUpdatesEnabled(true); updateTagLayoutHeight(); QTimer::singleShot(0, this, [this]() { updateTagLayoutHeight(); }); } void DetailView::applyCastSection(const MediaItem &item) { if (!m_castWidget) return; const QString fp = castFingerprint(item); if (!fp.isEmpty() && fp == m_appliedCastFingerprint) return; if (item.people.isEmpty()) { applyReservedSectionItems(m_castSectionReserveWidget, m_castWidget, {}); m_appliedCastFingerprint = fp; return; } QList castItems; castItems.reserve(item.people.size()); for (const auto &person : item.people) { MediaItem fakeItem; fakeItem.id = person.id; fakeItem.name = person.name; fakeItem.images.primaryTag = person.primaryImageTag; QStringList personParts; if (!person.type.isEmpty()) { static const QHash typeMap = { {"Actor", QT_TR_NOOP("Actor")}, {"Director", QT_TR_NOOP("Director")}, {"Writer", QT_TR_NOOP("Writer")}, {"Producer", QT_TR_NOOP("Producer")}, {"Composer", QT_TR_NOOP("Composer")}, {"Conductor", QT_TR_NOOP("Conductor")}, {"GuestStar", QT_TR_NOOP("GuestStar")}, {"Editor", QT_TR_NOOP("Editor")}, {"Lyricist", QT_TR_NOOP("Lyricist")}, }; auto it = typeMap.constFind(person.type); personParts << (it != typeMap.constEnd() ? tr(it.value()) : person.type); } if (!person.role.isEmpty()) personParts << person.role; fakeItem.overview = personParts.join(" · "); fakeItem.type = "Person"; castItems.append(fakeItem); } applyReservedSectionItems(m_castSectionReserveWidget, m_castWidget, castItems); m_appliedCastFingerprint = fp; } void DetailView::applyCollectionGalleryLayout( const QList &collections) { int landscapeSignals = 0; int portraitSignals = 0; const auto hasLandscapeArtwork = [](const MediaItem &item) { const double primaryAspectRatio = item.images.primaryImageAspectRatio; if (primaryAspectRatio > 1.05) return true; if (primaryAspectRatio > 0.0) return false; if (!item.images.primaryTag.isEmpty()) return false; return !item.images.thumbTag.isEmpty() || !item.images.backdropTag.isEmpty() || !item.images.parentThumbTag.isEmpty() || !item.images.parentBackdropTag.isEmpty(); }; for (const MediaItem &collection : collections) { if (hasLandscapeArtwork(collection)) { ++landscapeSignals; } else if (!collection.images.primaryTag.isEmpty() || collection.images.primaryImageAspectRatio > 0.0) { ++portraitSignals; } } const bool useLandscapeCards = landscapeSignals > 0 && landscapeSignals > portraitSignals; if (useLandscapeCards) { m_collectionWidget->setCardStyle(MediaCardDelegate::LibraryTile); m_collectionWidget->setGalleryHeight(230); } else { m_collectionWidget->setCardStyle(MediaCardDelegate::Poster); m_collectionWidget->setGalleryHeight(300); } qDebug() << "[DetailView] Collection gallery layout" << "itemCount=" << collections.size() << "landscapeSignals=" << landscapeSignals << "portraitSignals=" << portraitSignals << "style=" << (useLandscapeCards ? "LibraryTile" : "Poster"); } QCoro::Task DetailView::executeFetchSecondaries(QPointer safeThis, QEmbyCore *core, QString targetId, QString itemType) { if (!safeThis) co_return; if (itemType == "Series") { if (!safeThis->m_hasManualSeriesSelection && safeThis->m_hasCachedSeriesPlayableItem && safeThis->m_cachedSeriesPlayableItem.type == "Episode" && !safeThis->m_cachedSeriesPlayableItem.id.isEmpty()) { safeThis->applySeriesPlayableItemToUi( safeThis->m_cachedSeriesPlayableItem); qDebug() << "[DetailView][cache-ui] Applied cached series playable item" << "seriesId=" << targetId << "episodeId=" << safeThis->m_cachedSeriesPlayableItem.id; } qDebug() << "[DetailView][network] Fetch series next-up" << "seriesId=" << targetId << "applyToUi=" << !safeThis->m_hasManualSeriesSelection; co_await safeThis->fetchSeriesNextUp( targetId, !safeThis->m_hasManualSeriesSelection); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; qDebug() << "[DetailView][network] Fetch series seasons" << "seriesId=" << targetId; auto seasons = co_await core->mediaService()->getSeasons(targetId); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; bool seasonsUnchanged = false; if (safeThis->m_appliedCachedSeriesSeasonsToUi) { seasonsUnchanged = co_await mediaItemListsEqualAsync( safeThis->m_cachedSeriesSeasons, seasons); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; } safeThis->m_seriesSeasons = seasons; safeThis->m_cachedSeriesSeasons = seasons; safeThis->m_hasCachedSeriesSeasons = true; int targetSeasonIdx = 0; bool selectedSeasonResolved = false; if (safeThis->m_hasManualSeriesSelection && !safeThis->m_manualSelectedSeasonId.isEmpty()) { for (int i = 0; i < seasons.size(); ++i) { if (seasons[i].id == safeThis->m_manualSelectedSeasonId) { targetSeasonIdx = i; selectedSeasonResolved = true; break; } } } if (!selectedSeasonResolved && safeThis->m_hasManualSeriesSelection && safeThis->m_manualSelectedSeasonIndex >= 0 && safeThis->m_manualSelectedSeasonIndex < seasons.size()) { targetSeasonIdx = safeThis->m_manualSelectedSeasonIndex; selectedSeasonResolved = true; } if (!selectedSeasonResolved && safeThis->m_hasManualSeriesSelection && safeThis->m_currentSeasonIndex >= 0 && safeThis->m_currentSeasonIndex < seasons.size()) { targetSeasonIdx = safeThis->m_currentSeasonIndex; selectedSeasonResolved = true; } int playableParentIdx = safeThis->m_currentPlayableItem.parentIndexNumber; if (!selectedSeasonResolved && !safeThis->m_hasManualSeriesSelection && playableParentIdx >= 0) { for (int i = 0; i < seasons.size(); ++i) { if (seasons[i].indexNumber == playableParentIdx) { targetSeasonIdx = i; break; } } } if (!seasons.isEmpty()) targetSeasonIdx = qBound(0, targetSeasonIdx, seasons.size() - 1); safeThis->m_currentSeasonIndex = targetSeasonIdx; if (!seasons.isEmpty() && safeThis->m_episodeWidget) { safeThis->updateSeasonSwitcher(targetSeasonIdx); const QString playableItemId = safeThis->m_hasManualSeriesSelection && !safeThis->m_manualSelectedEpisodeId.isEmpty() ? safeThis->m_manualSelectedEpisodeId : safeThis->m_currentPlayableItem.id; co_await safeThis->loadEpisodesForSeason( targetSeasonIdx, playableItemId, !playableItemId.isEmpty(), safeThis->m_hasManualSeriesSelection); } else if (safeThis->m_episodeWidget) { applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, safeThis->m_episodeWidget, {}); } if (!safeThis || safeThis->m_currentItemId != targetId) co_return; if (safeThis->m_seasonWidget && !seasonsUnchanged) { safeThis->m_seasonWidget->setTitle(tr("Seasons")); safeThis->m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); safeThis->m_seasonWidget->setGalleryHeight(300); applyReservedSectionItems(safeThis->m_seasonSectionReserveWidget, safeThis->m_seasonWidget, seasons); } } if (safeThis->m_currentItemId != targetId) co_return; qDebug() << "[DetailView][ui] Apply cast section" << "itemId=" << targetId << "afterSeriesSections=" << (itemType == "Series"); safeThis->applyCastSection(safeThis->m_currentMediaItem); if (safeThis->m_deferBottomInfoReveal) { safeThis->m_deferBottomInfoReveal = false; hideReserveWidget(safeThis->m_bottomInfoReserveWidget); if (safeThis->m_bottomInfoWidget) safeThis->m_bottomInfoWidget->show(); } if (!safeThis) co_return; try { qDebug() << "[DetailView][network] Fetch similar items" << "itemId=" << targetId; QList similar = co_await core->mediaService()->getSimilarItems(targetId, 15); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; bool similarUnchanged = false; if (safeThis->m_appliedCachedSimilarItemsToUi) { similarUnchanged = co_await mediaItemListsEqualAsync( safeThis->m_cachedSimilarItems, similar); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; } safeThis->m_cachedSimilarItems = similar; safeThis->m_hasCachedSimilarItems = true; if (!similarUnchanged) { applyReservedSectionItems(safeThis->m_similarSectionReserveWidget, safeThis->m_similarWidget, similar); } } catch (...) { if (safeThis && safeThis->m_currentItemId == targetId && !safeThis->m_appliedCachedSimilarItemsToUi) { applyReservedSectionItems(safeThis->m_similarSectionReserveWidget, safeThis->m_similarWidget, {}); } } if (!safeThis) co_return; try { qDebug() << "[DetailView][network] Fetch item collections" << "itemId=" << targetId; QList collections = co_await core->mediaService()->getItemCollections(targetId); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; bool collectionsUnchanged = false; if (safeThis->m_appliedCachedCollectionsToUi) { collectionsUnchanged = co_await mediaItemListsEqualAsync( safeThis->m_cachedCollections, collections); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; } safeThis->m_cachedCollections = collections; safeThis->m_hasCachedCollections = true; if (!collectionsUnchanged) { safeThis->applyCollectionGalleryLayout(collections); applyReservedSectionItems(safeThis->m_collectionSectionReserveWidget, safeThis->m_collectionWidget, collections); } } catch (...) { if (safeThis && safeThis->m_currentItemId == targetId && !safeThis->m_appliedCachedCollectionsToUi) { applyReservedSectionItems(safeThis->m_collectionSectionReserveWidget, safeThis->m_collectionWidget, {}); } } if (!safeThis) co_return; try { qDebug() << "[DetailView][network] Fetch additional parts" << "itemId=" << targetId; QList additionalParts = co_await core->mediaService()->getAdditionalParts(targetId); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; bool additionalPartsUnchanged = false; if (safeThis->m_appliedCachedAdditionalPartsToUi) { additionalPartsUnchanged = co_await mediaItemListsEqualAsync( safeThis->m_cachedAdditionalParts, additionalParts); if (!safeThis || safeThis->m_currentItemId != targetId) co_return; } safeThis->m_cachedAdditionalParts = additionalParts; safeThis->m_hasCachedAdditionalParts = true; if (!additionalPartsUnchanged) { applyReservedSectionItems(safeThis->m_additionalPartsSectionReserveWidget, safeThis->m_additionalPartsWidget, additionalParts); } } catch (...) { if (safeThis && safeThis->m_currentItemId == targetId && !safeThis->m_appliedCachedAdditionalPartsToUi) { applyReservedSectionItems( safeThis->m_additionalPartsSectionReserveWidget, safeThis->m_additionalPartsWidget, {}); } } if (safeThis && safeThis->m_currentItemId == targetId) safeThis->persistDetailCacheSnapshot(QStringLiteral("secondaries")); } QCoro::Task DetailView::fetchSeriesNextUp(const QString &targetId, bool applyToUi) { QPointer safeThis(this); try { MediaItem playableItem; qDebug() << "[DetailView][network] Fetch next-up" << "seriesId=" << targetId; auto nextUp = co_await m_core->mediaService()->getNextUp(targetId); if (!nextUp.isEmpty()) playableItem = nextUp.first(); else { qDebug() << "[DetailView][network] Fetch seasons for next-up fallback" << "seriesId=" << targetId; auto seasons = co_await m_core->mediaService()->getSeasons(targetId); if (!seasons.isEmpty()) { qDebug() << "[DetailView][network] Fetch episodes for next-up fallback" << "seriesId=" << targetId << "seasonId=" << seasons.first().id; auto episodes = co_await m_core->mediaService()->getEpisodes( targetId, seasons.first().id); if (!episodes.isEmpty()) playableItem = episodes.first(); } } if (safeThis && safeThis->m_currentItemId == targetId && !playableItem.id.isEmpty()) { safeThis->m_cachedSeriesPlayableItem = playableItem; safeThis->m_hasCachedSeriesPlayableItem = true; if (!applyToUi || safeThis->m_hasManualSeriesSelection) { qDebug() << "[DetailView][network] Updated server next-up cache without " "changing manual selection" << "seriesId=" << targetId << "episodeId=" << playableItem.id << "applyToUi=" << applyToUi << "manualSelection=" << safeThis->m_hasManualSeriesSelection; safeThis->persistDetailCacheSnapshot( QStringLiteral("series-server-next-up")); co_return; } co_await safeThis->applySeriesPlayableItem(playableItem); } } catch (...) { } } void DetailView::applySeriesPlayableItemToUi(const MediaItem &playableItem, bool scrollToEpisode) { if (playableItem.id.isEmpty() || m_currentMediaItem.type != "Series") return; const QString playableFingerprint = DetailCacheUtils::fingerprint(playableItem); const bool playableUnchanged = !m_appliedSeriesPlayableFingerprint.isEmpty() && playableFingerprint == m_appliedSeriesPlayableFingerprint; m_currentPlayableItem = playableItem; const QString episodeTag = formatEpisodeTag(playableItem); if (!playableUnchanged) { const QString actionTag = episodeTag.isEmpty() ? playableItem.name : episodeTag; m_actionWidget->setupSeriesMode(playableItem, actionTag); updateMetaRow(m_currentMediaItem, episodeTag); m_actionWidget->setSources(playableItem.mediaSources, 0); m_appliedSourcesFingerprint = computeSourcesFingerprint(playableItem.mediaSources); QList selectedSources; if (!playableItem.mediaSources.isEmpty()) { int sourceIndex = m_actionWidget->currentSourceIndex(); if (sourceIndex >= playableItem.mediaSources.size()) sourceIndex = 0; const MediaSourceInfo &selectedSource = playableItem.mediaSources[sourceIndex]; m_actionWidget->setStreams(selectedSource); selectedSources.append(selectedSource); } else { m_actionWidget->setStreams(MediaSourceInfo{}); } if (!selectedSources.isEmpty()) { const QString bottomInfoFingerprint = computeBottomInfoFingerprint(playableItem, selectedSources); if (bottomInfoFingerprint != m_appliedBottomInfoFingerprint) { m_bottomInfoWidget->setInfo(playableItem, selectedSources); m_appliedBottomInfoFingerprint = bottomInfoFingerprint; m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); if (m_deferBottomInfoReveal) { showReserveWidget(m_bottomInfoReserveWidget, reserveHeightFor(m_bottomInfoWidget, 220)); } else { hideReserveWidget(m_bottomInfoReserveWidget); } } else { m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); } } m_appliedSeriesPlayableFingerprint = playableFingerprint; } m_actionWidget->refreshExtPlayerButton(); m_actionWidget->setPlayedState(playableItem.userData.played); if (m_episodeWidget) { m_episodeWidget->gallery()->setHighlightedItemId(playableItem.id); if (scrollToEpisode) m_episodeWidget->gallery()->scrollToItemId(playableItem.id); } qDebug() << "[DetailView] Apply series playable item" << "seriesId=" << m_currentItemId << "episodeId=" << playableItem.id << "episodeTag=" << episodeTag << "sourceCount=" << playableItem.mediaSources.size(); } QCoro::Task DetailView::applySeriesPlayableItem(MediaItem playableItem, bool scrollToEpisode) { if (playableItem.id.isEmpty() || m_currentMediaItem.type != "Series") co_return; QPointer safeThis(this); const QString seriesId = m_currentItemId; const QString playableItemId = playableItem.id; applySeriesPlayableItemToUi(playableItem, scrollToEpisode); rememberSeriesSelection(playableItem, QStringLiteral("series-selection"), true); const bool needsPlaybackInfo = playableItem.mediaSources.isEmpty() || playableItem.mediaSources.first().mediaStreams.isEmpty(); if (!needsPlaybackInfo) co_return; try { PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(playableItemId); if (!safeThis || safeThis->m_currentItemId != seriesId || safeThis->m_currentMediaItem.type != "Series" || safeThis->m_currentPlayableItem.id != playableItemId) co_return; if (!info.mediaSources.isEmpty()) { playableItem.mediaSources = info.mediaSources; safeThis->applySeriesPlayableItemToUi(playableItem, false); safeThis->rememberSeriesSelection( playableItem, QStringLiteral("series-selection-playback-info"), true); } } catch (...) { } } QCoro::Task DetailView::onVersionChanged(int index) { const bool usePlayableItem = m_currentMediaItem.type == "Series" && !m_currentPlayableItem.id.isEmpty(); MediaItem actionItem = usePlayableItem ? m_currentPlayableItem : m_currentMediaItem; if (index < 0 || index >= actionItem.mediaSources.size()) co_return; MediaSourceInfo source = actionItem.mediaSources[index]; QPointer safeThis(this); const QString expectedPageId = m_currentItemId; const QString actionItemId = actionItem.id; if (source.mediaStreams.isEmpty()) { try { PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(actionItemId); if (!safeThis || safeThis->m_currentItemId != expectedPageId) co_return; if (!info.mediaSources.isEmpty()) { if (usePlayableItem) { if (safeThis->m_currentPlayableItem.id != actionItemId) co_return; safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; actionItem = safeThis->m_currentPlayableItem; } else { safeThis->m_currentMediaItem.mediaSources = info.mediaSources; safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; actionItem = safeThis->m_currentMediaItem; } int selectedIndex = index; if (selectedIndex >= actionItem.mediaSources.size()) selectedIndex = 0; if (selectedIndex < actionItem.mediaSources.size()) { source = actionItem.mediaSources[selectedIndex]; } } } catch (...) { co_return; } } if (!safeThis || source.mediaStreams.isEmpty()) co_return; safeThis->m_actionWidget->setStreams(source); QList singleSource; singleSource.append(source); const MediaItem infoItem = usePlayableItem ? safeThis->m_currentPlayableItem : safeThis->m_currentMediaItem; safeThis->m_bottomInfoWidget->setInfo(infoItem, singleSource); hideReserveWidget(safeThis->m_bottomInfoReserveWidget); } QCoro::Task DetailView::executePlay(MediaItem targetItem, long long startTicks) { if (targetItem.id.isEmpty()) co_return; try { MediaItem actualItem = targetItem; if (actualItem.mediaSources.isEmpty()) { PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); actualItem.mediaSources = info.mediaSources; } if (actualItem.mediaSources.isEmpty()) co_return; int sourceIdx = 0; int selectedAudioIdx = -1; int selectedSubIdx = -1; const bool useUiSelection = isCurrentActionItem(actualItem.id); if (useUiSelection) { sourceIdx = m_actionWidget->currentSourceIndex(); selectedAudioIdx = m_actionWidget->currentAudioIndex(); selectedSubIdx = m_actionWidget->currentSubtitleIndex(); } else { sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( actualItem.mediaSources, ConfigStore::instance() ->get(ConfigKeys::PlayerPreferredVersion) .trimmed()); } if (sourceIdx >= actualItem.mediaSources.size()) sourceIdx = 0; MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; if (useUiSelection) { for (auto &stream : modifiedSource.mediaStreams) { if (stream.type == "Audio" && selectedAudioIdx >= 0) stream.isDefault = (stream.index == selectedAudioIdx); else if (stream.type == "Subtitle" && selectedSubIdx >= 0) stream.isDefault = (stream.index == selectedSubIdx); } } else { PlayerPreferenceUtils::applyPreferredStreamRules( modifiedSource, ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, "auto"), ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); } QString streamUrl = m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); const QString playTitle = MediaItemUtils::playbackTitle(actualItem, m_currentMediaItem.name); PlaybackManager::instance()->startInternalPlayback( actualItem.id, playTitle, streamUrl, startTicks, QVariant::fromValue(modifiedSource)); } catch (...) { } } QCoro::Task DetailView::executePlaySeason(MediaItem seasonItem) { if (seasonItem.id.isEmpty() || m_currentItemId.isEmpty() || !m_core || !m_core->mediaService()) co_return; QPointer safeThis(this); const QString seriesId = m_currentItemId; const QString seasonId = seasonItem.id; try { QList episodes = co_await m_core->mediaService()->getEpisodes(seriesId, seasonId); if (!safeThis || safeThis->m_currentItemId != seriesId) co_return; MediaItem targetEpisode = resolveSeasonPlaybackEpisode(episodes); if (targetEpisode.id.isEmpty()) co_return; qDebug() << "[DetailView] Play season" << "seriesId=" << seriesId << "seasonId=" << seasonId << "episodeId=" << targetEpisode.id << "episodeTag=" << formatEpisodeTag(targetEpisode) << "episodeCount=" << episodes.size() << "startTicks=" << targetEpisode.userData.playbackPositionTicks; co_await safeThis->applySeriesPlayableItem(targetEpisode, false); if (!safeThis || safeThis->m_currentItemId != seriesId) co_return; const MediaItem playbackItem = safeThis->m_currentPlayableItem.id == targetEpisode.id ? safeThis->m_currentPlayableItem : targetEpisode; co_await safeThis->executePlay( playbackItem, playbackItem.userData.playbackPositionTicks); } catch (...) { } } QCoro::Task DetailView::executeExternalPlay(MediaItem targetItem, QString playerPath) { if (targetItem.id.isEmpty() || playerPath.isEmpty()) co_return; try { MediaItem actualItem = targetItem; if (actualItem.mediaSources.isEmpty()) { PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); actualItem.mediaSources = info.mediaSources; } if (actualItem.mediaSources.isEmpty()) co_return; int sourceIdx = 0; const bool useUiSelection = isCurrentActionItem(actualItem.id); if (useUiSelection) { sourceIdx = m_actionWidget->currentSourceIndex(); } if (sourceIdx >= actualItem.mediaSources.size()) sourceIdx = 0; MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; if (useUiSelection) { int selectedAudioIdx = m_actionWidget->currentAudioIndex(); int selectedSubIdx = m_actionWidget->currentSubtitleIndex(); for (auto &stream : modifiedSource.mediaStreams) { if (stream.type == "Audio" && selectedAudioIdx >= 0) stream.isDefault = (stream.index == selectedAudioIdx); else if (stream.type == "Subtitle" && selectedSubIdx >= 0) stream.isDefault = (stream.index == selectedSubIdx); } } else { PlayerPreferenceUtils::applyPreferredStreamRules( modifiedSource, ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, "auto"), ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); } QString streamUrl = m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); const QString playTitle = MediaItemUtils::playbackTitle(actualItem, m_currentMediaItem.name); long long startTicks = actualItem.userData.playbackPositionTicks; PlaybackManager::instance()->startExternalPlayback( playerPath, actualItem.id, playTitle, streamUrl, startTicks, QVariant::fromValue(modifiedSource)); } catch (...) { } } void DetailView::updateBackdrop() { if (m_contentWidget) static_cast(m_contentWidget) ->setBackdrop(m_currentBackdropPix); } void DetailView::updateTagLayoutHeight() { if (!m_tagsWidget || !m_tagsLayout) return; int targetWidth = m_tagsWidget->width(); if (targetWidth <= 0 && m_textContainer) { targetWidth = m_textContainer->contentsRect().width(); } if (targetWidth <= 0) { m_tagsWidget->updateGeometry(); QTimer::singleShot(50, this, [this]() { updateTagLayoutHeight(); }); return; } m_tagsLayout->invalidate(); const int targetHeight = m_tagsLayout->heightForWidth(targetWidth); if (m_tagsWidget->minimumHeight() != targetHeight) { m_tagsWidget->setMinimumHeight(targetHeight); } m_tagsWidget->updateGeometry(); if (m_textContainer) { m_textContainer->updateGeometry(); } } void DetailView::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); if (m_overviewLabel && m_overviewLabel->width() > 10 && qAbs(m_overviewLabel->width() - m_lastOverviewWidth) > 5) { m_lastOverviewWidth = m_overviewLabel->width(); updateOverviewElidedText(); } updateTagLayoutHeight(); updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); } bool DetailView::eventFilter(QObject *obj, QEvent *event) { if (m_mainScrollArea && obj == m_mainScrollArea->viewport() && event->type() == QEvent::Resize) { QResizeEvent *re = static_cast(event); int newWidth = re->size().width(); if (m_contentWidget && newWidth > 100 && m_contentWidget->maximumWidth() != newWidth) { m_contentWidget->setMaximumWidth(newWidth); QTimer::singleShot(0, this, [this]() { updateTagLayoutHeight(); updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); }); } } if (m_episodeWidget && m_episodeWidget->gallery() && obj == m_episodeWidget->gallery()->listView()->viewport() && event->type() == QEvent::Resize) { QTimer::singleShot(0, this, [this]() { updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); }); } if (event->type() == QEvent::Wheel) { bool isHorizontalViewport = obj->parent() && obj->parent()->property("isHorizontalListView").toBool(); bool isMainViewport = (m_mainScrollArea && obj == m_mainScrollArea->viewport()); if (isHorizontalViewport || isMainViewport) { QWheelEvent *we = static_cast(event); if (m_vScrollController) { m_vScrollController->scrollByWheelEvent(we, Qt::Vertical); } return true; } } return QWidget::eventFilter(obj, event); } void DetailView::showEvent(QShowEvent *event) { BaseView::showEvent(event); if (m_skipNextSilentRefresh) { m_skipNextSilentRefresh = false; return; } m_pendingDeferredFetchId.clear(); if (!m_currentItemId.isEmpty() && m_core && m_core->mediaService()) { executeSilentRefresh(QPointer(this), m_core, m_currentItemId); } } void DetailView::clearLayout(QLayout *layout) { if (!layout) return; QLayoutItem *child; while ((child = layout->takeAt(0)) != nullptr) { if (child->widget()) { child->widget()->hide(); child->widget()->setParent(nullptr); child->widget()->deleteLater(); } else if (child->layout()) { clearLayout(child->layout()); } delete child; } } QString DetailView::formatRunTime(long long ticks) { long long totalSeconds = ticks / 10000000; long long hours = totalSeconds / 3600; long long minutes = (totalSeconds % 3600) / 60; if (hours > 0) return QString(tr("%1 hr %2 min")).arg(hours).arg(minutes); return QString(tr("%1 min")).arg(minutes); } bool DetailView::shouldShowDisplayNumber(const MediaItem &item) const { if (item.type.compare("Movie", Qt::CaseInsensitive) != 0) { return false; } static const QList adultMarkers = { QRegularExpression(R"((^|[^0-9])18\+?([^0-9]|$))", QRegularExpression::CaseInsensitiveOption), QRegularExpression(R"(\b(?:R[-\s]?18\+?|NC[-\s]?17|JAV|AV)\b)", QRegularExpression::CaseInsensitiveOption), QRegularExpression(QStringLiteral("(成人|无码|有码|無碼|有碼)"))}; const auto containsAdultMarker = [](const QString &value) -> bool { const QString trimmedValue = value.trimmed(); if (trimmedValue.isEmpty()) { return false; } for (const QRegularExpression &pattern : adultMarkers) { if (pattern.match(trimmedValue).hasMatch()) { return true; } } return false; }; if (containsAdultMarker(item.officialRating)) { return true; } for (const QString &genre : item.genres) { if (containsAdultMarker(genre)) { return true; } } for (const QString &tag : item.tags) { if (containsAdultMarker(tag)) { return true; } } return false; } bool DetailView::isCurrentActionItem(const QString &itemId) const { if (itemId.isEmpty()) return false; if (itemId == m_currentMediaItem.id) return true; return m_currentMediaItem.type == "Series" && itemId == m_currentPlayableItem.id; } QStringList DetailView::buildNumberCandidates(const MediaItem &item) const { QStringList candidates; const auto appendCandidate = [&candidates](const QString &value) { const QString trimmedValue = value.trimmed(); if (!trimmedValue.isEmpty() && !candidates.contains(trimmedValue)) { candidates.append(trimmedValue); } }; for (const MediaSourceInfo &source : item.mediaSources) { appendCandidate(source.path); } appendCandidate(item.path); appendCandidate(item.name); appendCandidate(item.originalTitle); appendCandidate(item.sortName); for (const QString &tagline : item.taglines) { appendCandidate(tagline); } for (const QString &tag : item.tags) { appendCandidate(tag); } return candidates; } QString DetailView::extractDisplayNumber(const MediaItem &item) const { static const NumberExtractor extractor(true); const QStringList candidates = buildNumberCandidates(item); const QString displayNumber = extractor.extractBestNumber(candidates); qDebug() << "[DetailView] adult number extraction itemId=" << item.id << "officialRating=" << item.officialRating << "candidateCount=" << candidates.size() << "selected=" << displayNumber; return displayNumber; } void DetailView::updateDisplayNumber(const QString &number) { if (!m_numberButton || !m_metaRowWidget) { return; } const QString trimmedNumber = number.trimmed(); const bool hasNumber = !trimmedNumber.isEmpty(); m_numberButton->setVisible(hasNumber); m_numberButton->setText(trimmedNumber); m_numberButton->setToolTip(hasNumber ? tr("Click to copy number") : QString()); m_metaRowWidget->setVisible(!m_metaLabel->text().isEmpty() || hasNumber); } void DetailView::copyDisplayedNumber() { if (!m_numberButton) { return; } const QString displayNumber = m_numberButton->text().trimmed(); if (displayNumber.isEmpty()) { return; } if (QClipboard *clipboard = QGuiApplication::clipboard()) { clipboard->setText(displayNumber); } qDebug() << "[DetailView] copied display number" << displayNumber << "for itemId" << m_currentItemId; ModernToast::showMessage(tr("Number copied: %1").arg(displayNumber), 2000); } void DetailView::markManualSeriesSelection(const QString &reason) { if (m_currentMediaItem.type != "Series") return; if (!m_hasManualSeriesSelection) { qDebug() << "[DetailView] Manual series selection locked server auto-sync" << "seriesId=" << m_currentItemId << "reason=" << reason; } m_hasManualSeriesSelection = true; } void DetailView::rememberSeriesSelection(const MediaItem &episode, const QString &reason, bool persist) { if (m_currentMediaItem.type != "Series") return; int selectedSeasonIndex = -1; QString selectedSeasonId; bool hasSelectedSeason = false; if (episode.type == "Episode" && episode.parentIndexNumber >= 0) { for (int i = 0; i < m_seriesSeasons.size(); ++i) { if (m_seriesSeasons[i].indexNumber == episode.parentIndexNumber) { selectedSeasonIndex = i; selectedSeasonId = m_seriesSeasons[i].id; hasSelectedSeason = true; break; } } } else { selectedSeasonIndex = m_currentSeasonIndex; if (selectedSeasonIndex >= 0 && selectedSeasonIndex < m_seriesSeasons.size()) { selectedSeasonId = m_seriesSeasons[selectedSeasonIndex].id; hasSelectedSeason = true; } } if (hasSelectedSeason) { if (persist) { m_cachedSeasonIndex = selectedSeasonIndex; m_cachedSeasonId = selectedSeasonId; } else { m_manualSelectedSeasonIndex = selectedSeasonIndex; m_manualSelectedSeasonId = selectedSeasonId; } } if (episode.type == "Episode" && !episode.id.isEmpty()) { if (persist) { m_cachedSeriesPlayableItem = episode; m_hasCachedSeriesPlayableItem = true; } else { m_manualSelectedEpisodeId = episode.id; } } qDebug() << "[DetailView] Remember series selection" << "seriesId=" << m_currentItemId << "seasonIndex=" << (persist ? m_cachedSeasonIndex : m_manualSelectedSeasonIndex) << "seasonId=" << (persist ? m_cachedSeasonId : m_manualSelectedSeasonId) << "episodeId=" << (persist ? m_cachedSeriesPlayableItem.id : m_manualSelectedEpisodeId) << "persist=" << persist << "reason=" << reason; if (persist) persistDetailCacheSnapshot(reason); } void DetailView::updateOverviewElidedText() { if (m_cleanOverviewText.isEmpty() || !m_overviewLabel) return; const int generation = ++m_overviewElideGeneration; QPointer safeThis(this); QTimer::singleShot(0, this, [safeThis, generation]() { if (!safeThis || generation != safeThis->m_overviewElideGeneration) return; int labelWidth = safeThis->m_overviewLabel->contentsRect().width(); if (labelWidth <= 10 && safeThis->m_textContainer) labelWidth = safeThis->m_textContainer->contentsRect().width(); if (labelWidth <= 10) return; QFontMetrics fm(safeThis->m_overviewLabel->font()); int maxHeight = fm.lineSpacing() * 5 + 10; QTextDocument doc; doc.setDefaultFont(safeThis->m_overviewLabel->font()); doc.setTextWidth(labelWidth); QString fullHtml = safeThis->m_cleanOverviewText.toHtmlEscaped(); fullHtml.replace("\n", "
"); doc.setHtml(fullHtml); if (doc.size().height() <= maxHeight) { if (safeThis->m_overviewLabel->text() != fullHtml) safeThis->m_overviewLabel->setText(fullHtml); return; } int low = 0, high = safeThis->m_cleanOverviewText.length(), best = 0; QString moreLink = safeThis->tr("... More"); while (low <= high) { int mid = low + (high - low) / 2; QString testHtml = safeThis->m_cleanOverviewText.left(mid).toHtmlEscaped(); testHtml.replace("\n", "
"); doc.setHtml(testHtml + moreLink); if (doc.size().height() <= maxHeight) { best = mid; low = mid + 1; } else { high = mid - 1; } } QString finalHtml = safeThis->m_cleanOverviewText.left(best).toHtmlEscaped(); finalHtml.replace("\n", "
"); const QString overviewHtml = finalHtml + moreLink; if (safeThis->m_overviewLabel->text() != overviewHtml) safeThis->m_overviewLabel->setText(overviewHtml); }); } QCoro::Task DetailView::updateUi(MediaItem item, bool isSilentRefresh) { m_currentMediaItem = item; if (!isSilentRefresh && item.type == "Series") m_deferBottomInfoReveal = true; m_titleLabel->setText(item.name); const QString metaEpisodeTag = item.type == "Series" && m_currentPlayableItem.type == "Episode" ? formatEpisodeTag(m_currentPlayableItem) : formatEpisodeTag(item); updateMetaRow(item, metaEpisodeTag); const bool shouldShowNumber = shouldShowDisplayNumber(item); const QString displayNumber = shouldShowNumber ? extractDisplayNumber(item) : QString(); updateDisplayNumber(displayNumber); qDebug() << "[DetailView] updateUi itemId=" << item.id << "adultMovie=" << shouldShowNumber << "displayNumber=" << displayNumber; QString text = item.overview; text.replace(QRegularExpression("", QRegularExpression::CaseInsensitiveOption), "\n"); text.replace("

", "\n\n"); text.remove(QRegularExpression("<[^>]*>")); QTextDocument doc; doc.setHtml(text); QString cleanText = doc.toPlainText(); cleanText.replace(QChar::LineSeparator, '\n'); cleanText.remove(QRegularExpression("<[^>]*>")); m_cleanOverviewText = cleanText; m_lastOverviewWidth = -1; updateOverviewElidedText(); if (item.type == "Series") { const bool hasCurrentEpisode = m_currentPlayableItem.type == "Episode" && !m_currentPlayableItem.id.isEmpty(); if (!isSilentRefresh && !hasCurrentEpisode) m_actionWidget->setSeriesLoadingMode(); } else { m_currentPlayableItem = item; m_actionWidget->setupNormalMode(item); } const QString newSourcesFingerprint = computeSourcesFingerprint(item.mediaSources); const bool sourcesUnchanged = !m_appliedSourcesFingerprint.isEmpty() && newSourcesFingerprint == m_appliedSourcesFingerprint; const bool sourcesOwnedBySeriesEpisode = item.type == "Series" && m_currentPlayableItem.type == "Episode" && !m_currentPlayableItem.id.isEmpty(); if (!sourcesOwnedBySeriesEpisode && !sourcesUnchanged) { m_actionWidget->setSources(item.mediaSources, 0); m_appliedSourcesFingerprint = newSourcesFingerprint; if (item.mediaSources.isEmpty() || item.mediaSources.first().mediaStreams.isEmpty()) { m_actionWidget->setStreams(MediaSourceInfo{}); } } m_actionWidget->refreshExtPlayerButton(); if (!item.taglines.isEmpty()) { m_taglineLabel->setText(item.taglines.first()); m_taglineLabel->show(); } else { m_taglineLabel->hide(); } m_isFavorite = item.isFavorite(); m_actionWidget->setFavoriteState(m_isFavorite); m_actionWidget->setPlayedState(item.userData.played); buildTagButtons(item.genres); QCoro::connect(executeLoadImages(QPointer(this), m_core, item, true), this, []() {}); QPointer safeThis(this); auto applyBottomInfo = [this, &item](const QList &srcs) { const QString fp = computeBottomInfoFingerprint(item, srcs); if (fp == m_appliedBottomInfoFingerprint) return; m_bottomInfoWidget->setInfo(item, srcs); m_appliedBottomInfoFingerprint = fp; if (m_deferBottomInfoReveal) { showReserveWidget(m_bottomInfoReserveWidget, reserveHeightFor(m_bottomInfoWidget, 220)); } }; if (sourcesOwnedBySeriesEpisode) { applySeriesPlayableItemToUi(m_currentPlayableItem); } else if (!item.mediaSources.isEmpty() && !item.mediaSources.first().mediaStreams.isEmpty()) { int defaultIndex = m_actionWidget->currentSourceIndex(); QList defaultSource; if (defaultIndex < item.mediaSources.size()) { const MediaSourceInfo &dSrc = item.mediaSources[defaultIndex]; if (!sourcesUnchanged) m_actionWidget->setStreams(dSrc); defaultSource.append(dSrc); } applyBottomInfo(defaultSource); } else { applyBottomInfo({}); if (item.mediaType == "Video" || item.mediaType == "Audio" || item.type == "Movie" || item.type == "Episode") { try { PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(item.id); if (!safeThis) co_return; if (item.id == safeThis->m_currentItemId && !info.mediaSources.isEmpty()) { safeThis->m_currentMediaItem.mediaSources = info.mediaSources; const QString refreshedNumber = safeThis->shouldShowDisplayNumber(safeThis->m_currentMediaItem) ? safeThis->extractDisplayNumber(safeThis->m_currentMediaItem) : QString(); safeThis->updateDisplayNumber(refreshedNumber); if (item.type != "Series") { safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; } int idx = safeThis->m_actionWidget->currentSourceIndex(); if (idx >= info.mediaSources.size()) idx = 0; safeThis->m_actionWidget->setSources(info.mediaSources, idx); safeThis->m_appliedSourcesFingerprint = computeSourcesFingerprint(info.mediaSources); idx = safeThis->m_actionWidget->currentSourceIndex(); if (idx >= safeThis->m_currentMediaItem.mediaSources.size()) idx = 0; if (idx < safeThis->m_currentMediaItem.mediaSources.size()) { const MediaSourceInfo &singleSource = safeThis->m_currentMediaItem.mediaSources[idx]; safeThis->m_actionWidget->setStreams(singleSource); QList sList; sList.append(singleSource); const QString fp = computeBottomInfoFingerprint( safeThis->m_currentMediaItem, sList); if (fp != safeThis->m_appliedBottomInfoFingerprint) { safeThis->m_bottomInfoWidget->setInfo( safeThis->m_currentMediaItem, sList); safeThis->m_appliedBottomInfoFingerprint = fp; if (safeThis->m_deferBottomInfoReveal) { showReserveWidget( safeThis->m_bottomInfoReserveWidget, reserveHeightFor(safeThis->m_bottomInfoWidget, 220)); } } } } } catch (...) { } } } if (!safeThis) co_return; safeThis->m_bottomInfoWidget->setVisible( !safeThis->m_deferBottomInfoReveal); if (safeThis->m_deferBottomInfoReveal) { showReserveWidget(safeThis->m_bottomInfoReserveWidget, reserveHeightFor(safeThis->m_bottomInfoWidget, 220)); } else { hideReserveWidget(safeThis->m_bottomInfoReserveWidget); } if (!isSilentRefresh && safeThis) QMetaObject::invokeMethod(safeThis.data(), "scrollToTop", Qt::QueuedConnection); } void DetailView::onOverviewMoreClicked(const QString &link) { if (link == "more") { OverviewDialog dialog(this); dialog.setMediaItem(m_currentMediaItem, m_currentPosterPix); dialog.exec(); } } QCoro::Task DetailView::executeSilentRefresh(QPointer safeThis, QEmbyCore *core, QString itemId) { QString cacheServerId; QString cacheUserId; if (core && core->serverManager()) { const ServerProfile profile = core->serverManager()->activeProfile(); cacheServerId = profile.id; cacheUserId = profile.userId; } try { MediaItem item = co_await core->mediaService()->getItemDetail(itemId); if (!cacheServerId.isEmpty() && !cacheUserId.isEmpty()) { const QString fp = co_await QtConcurrent::run( [item]() mutable { return DetailCacheUtils::fingerprint(item); }); saveDetailCacheAsync(cacheServerId, cacheUserId, item, fp); if (safeThis && item.id == safeThis->m_currentItemId) safeThis->m_cachedDetailFingerprint = fp; } if (safeThis && item.id == safeThis->m_currentItemId) { co_await safeThis->updateUi(item, true); if (item.type == "Series") { co_await safeThis->fetchSeriesNextUp( itemId, !safeThis->m_hasManualSeriesSelection); if (!safeThis || safeThis->m_currentItemId != itemId) co_return; auto seasons = co_await core->mediaService()->getSeasons(itemId); if (!safeThis || safeThis->m_currentItemId != itemId) co_return; safeThis->m_seriesSeasons = seasons; safeThis->m_cachedSeriesSeasons = seasons; safeThis->m_hasCachedSeriesSeasons = true; bool selectedSeasonResolved = false; if (safeThis->m_hasManualSeriesSelection && !safeThis->m_manualSelectedSeasonId.isEmpty()) { for (int i = 0; i < seasons.size(); ++i) { if (seasons[i].id == safeThis->m_manualSelectedSeasonId) { safeThis->m_currentSeasonIndex = i; selectedSeasonResolved = true; break; } } } if (!selectedSeasonResolved && safeThis->m_hasManualSeriesSelection && safeThis->m_manualSelectedSeasonIndex >= 0 && safeThis->m_manualSelectedSeasonIndex < seasons.size()) { safeThis->m_currentSeasonIndex = safeThis->m_manualSelectedSeasonIndex; selectedSeasonResolved = true; } if (!selectedSeasonResolved && safeThis->m_hasManualSeriesSelection && safeThis->m_currentSeasonIndex >= 0 && safeThis->m_currentSeasonIndex < seasons.size()) { selectedSeasonResolved = true; } int playableParentIdx = safeThis->m_currentPlayableItem.parentIndexNumber; if (!selectedSeasonResolved && !safeThis->m_hasManualSeriesSelection && playableParentIdx >= 0) { for (int i = 0; i < seasons.size(); ++i) { if (seasons[i].indexNumber == playableParentIdx) { safeThis->m_currentSeasonIndex = i; break; } } } if (!seasons.isEmpty() && safeThis->m_episodeWidget) { const int idx = qBound(0, safeThis->m_currentSeasonIndex, seasons.size() - 1); safeThis->updateSeasonSwitcher(idx); const QString playableItemId = safeThis->m_hasManualSeriesSelection && !safeThis->m_manualSelectedEpisodeId.isEmpty() ? safeThis->m_manualSelectedEpisodeId : safeThis->m_currentPlayableItem.id; co_await safeThis->loadEpisodesForSeason( idx, playableItemId, !playableItemId.isEmpty(), safeThis->m_hasManualSeriesSelection); } else if (safeThis->m_episodeWidget) { applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, safeThis->m_episodeWidget, {}); } if (!safeThis || safeThis->m_currentItemId != itemId) co_return; if (safeThis->m_seasonWidget) { safeThis->m_seasonWidget->setTitle(tr("Seasons")); safeThis->m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); safeThis->m_seasonWidget->setGalleryHeight(300); applyReservedSectionItems(safeThis->m_seasonSectionReserveWidget, safeThis->m_seasonWidget, seasons); } } safeThis->applyCastSection(item); if (safeThis->m_deferBottomInfoReveal) { safeThis->m_deferBottomInfoReveal = false; hideReserveWidget(safeThis->m_bottomInfoReserveWidget); if (safeThis->m_bottomInfoWidget) safeThis->m_bottomInfoWidget->show(); } safeThis->persistDetailCacheSnapshot(QStringLiteral("silent-refresh")); } } catch (...) { } } QCoro::Task DetailView::executeLoadImages(QPointer safeThis, QEmbyCore *core, MediaItem item, bool allowPrimaryBackdropFallback) { bool adaptive = ConfigStore::instance()->get(ConfigKeys::AdaptiveImages, true); QString cacheServerId; QString cacheUserId; if (safeThis) { cacheServerId = safeThis->m_detailCacheServerId; cacheUserId = safeThis->m_detailCacheUserId; } else if (core && core->serverManager()) { const ServerProfile profile = core->serverManager()->activeProfile(); cacheServerId = profile.id; cacheUserId = profile.userId; } QString posterType = QStringLiteral("Primary"); QString posterTag = item.images.primaryTag; QString posterId = item.id; if (posterTag.isEmpty() && adaptive) { auto best = item.images.bestPoster(); posterTag = best.first; posterType = best.second; if (item.images.isParentTag(posterTag)) { posterId = item.images.parentImageItemId.isEmpty() ? item.seriesId : item.images.parentImageItemId; } } const int posterMaxWidth = posterType == QStringLiteral("Primary") ? 400 : 800; QString backdropType = QStringLiteral("Backdrop"); QString backdropTag = item.images.backdropTag; QString backdropId = item.id; if (backdropTag.isEmpty() && adaptive) { if (allowPrimaryBackdropFallback) { auto best = item.images.bestBackdrop(); backdropTag = best.first; backdropType = best.second; } else { if (!item.images.thumbTag.isEmpty()) { backdropTag = item.images.thumbTag; backdropType = QStringLiteral("Thumb"); } else if (!item.images.parentBackdropTag.isEmpty()) { backdropTag = item.images.parentBackdropTag; backdropType = QStringLiteral("Backdrop"); } else if (!item.images.parentThumbTag.isEmpty()) { backdropTag = item.images.parentThumbTag; backdropType = QStringLiteral("Thumb"); } } if (item.images.isParentTag(backdropTag)) { backdropId = item.images.parentImageItemId.isEmpty() ? item.seriesId : item.images.parentImageItemId; } } const QString logoId = item.id; const QString logoType = QStringLiteral("Logo"); const QString logoTag = item.images.logoTag; constexpr int logoMaxWidth = 400; const CachedDetailImages cachedImages = co_await QtConcurrent::run( [cacheServerId, cacheUserId, ownerItemId = item.id, posterId, posterType, posterTag, posterMaxWidth, backdropId, backdropType, backdropTag, logoId, logoType, logoTag, logoMaxWidth]() mutable { CachedDetailImages result; if (!posterTag.isEmpty()) { result.poster = DetailCacheUtils::loadImage( cacheServerId, cacheUserId, ownerItemId, QStringLiteral("poster"), posterId, posterType, posterTag, posterMaxWidth); } if (!backdropTag.isEmpty()) { result.backdrop = DetailCacheUtils::loadImage( cacheServerId, cacheUserId, ownerItemId, QStringLiteral("backdrop"), backdropId, backdropType, backdropTag, 1920); } if (!logoTag.isEmpty()) { result.logo = DetailCacheUtils::loadImage( cacheServerId, cacheUserId, ownerItemId, QStringLiteral("logo"), logoId, logoType, logoTag, logoMaxWidth); } return result; }); if (!safeThis || safeThis->m_currentItemId != item.id) co_return; if (cachedImages.poster.has_value()) { safeThis->m_currentPosterPix = QPixmap::fromImage(cachedImages.poster.value()); safeThis->m_posterLabel->setPixmap(safeThis->m_currentPosterPix); qDebug() << "[DetailView] Detail poster image cache hit" << "itemId=" << item.id << "imageId=" << posterId << "type=" << posterType; } if (cachedImages.backdrop.has_value()) { safeThis->m_currentBackdropPix = QPixmap::fromImage(cachedImages.backdrop.value()); safeThis->updateBackdrop(); qDebug() << "[DetailView] Detail backdrop image cache hit" << "itemId=" << item.id << "imageId=" << backdropId << "type=" << backdropType; } if (cachedImages.logo.has_value()) { safeThis->m_logoLabel->setPixmap( QPixmap::fromImage(cachedImages.logo.value())); FadeHelper::fadeIn(safeThis->m_logoLabel, AnimationTokens::DurationMs::kNormal); qDebug() << "[DetailView] Detail logo image cache hit" << "itemId=" << item.id; } if (!posterTag.isEmpty()) { try { QPixmap pix = co_await core->mediaService()->fetchImage( posterId, posterType, posterTag, posterMaxWidth); if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { safeThis->m_currentPosterPix = pix; const QSize posterSize(250, 375); const QString currentItemId = item.id; QImage src = pix.toImage(); auto *watcher = new QFutureWatcher(safeThis.data()); QObject::connect( watcher, &QFutureWatcher::finished, safeThis.data(), [safeThis, watcher, currentItemId, cacheServerId, cacheUserId, posterId, posterType, posterTag, posterMaxWidth]() { if (safeThis && safeThis->m_currentItemId == currentItemId) { const QImage result = watcher->result(); if (!result.isNull()) { safeThis->m_posterLabel->setPixmap( QPixmap::fromImage(result)); saveDetailImageAsync(cacheServerId, cacheUserId, currentItemId, QStringLiteral("poster"), posterId, posterType, posterTag, posterMaxWidth, result); } } watcher->deleteLater(); }); watcher->setFuture(QtConcurrent::run([src, posterSize]() -> QImage { QImage scaled = src.scaled(posterSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); const int cropX = (scaled.width() - posterSize.width()) / 2; const int cropY = (scaled.height() - posterSize.height()) / 2; QImage cropped = scaled.copy(cropX, cropY, posterSize.width(), posterSize.height()); QImage rounded(posterSize, QImage::Format_ARGB32_Premultiplied); rounded.fill(Qt::transparent); QPainter p(&rounded); p.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addRoundedRect( QRectF(0, 0, posterSize.width(), posterSize.height()), 12, 12); p.setClipPath(path); p.drawImage(0, 0, cropped); p.end(); return rounded; })); } } catch (...) { } } if (!backdropTag.isEmpty()) { try { QPixmap pix = co_await core->mediaService()->fetchImage( backdropId, backdropType, backdropTag, 1920); if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { safeThis->m_currentBackdropPix = pix; safeThis->updateBackdrop(); saveDetailImageAsync(cacheServerId, cacheUserId, item.id, QStringLiteral("backdrop"), backdropId, backdropType, backdropTag, 1920, pix.toImage()); } } catch (...) { } } if (!logoTag.isEmpty()) { try { QPixmap pix = co_await core->mediaService()->fetchImage(logoId, logoType, logoTag, logoMaxWidth); if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { const QString currentItemId = item.id; QImage src = pix.toImage(); auto *watcher = new QFutureWatcher(safeThis.data()); QObject::connect( watcher, &QFutureWatcher::finished, safeThis.data(), [safeThis, watcher, currentItemId, cacheServerId, cacheUserId, logoId, logoType, logoTag, logoMaxWidth]() { if (safeThis && safeThis->m_currentItemId == currentItemId) { const QImage result = watcher->result(); if (!result.isNull()) { safeThis->m_logoLabel->setPixmap( QPixmap::fromImage(result)); FadeHelper::fadeIn(safeThis->m_logoLabel, AnimationTokens::DurationMs::kNormal); saveDetailImageAsync( cacheServerId, cacheUserId, currentItemId, QStringLiteral("logo"), logoId, logoType, logoTag, logoMaxWidth, result); } } watcher->deleteLater(); }); watcher->setFuture(QtConcurrent::run([src]() -> QImage { return src.scaled(250, 100, Qt::KeepAspectRatio, Qt::SmoothTransformation); })); } } catch (...) { } } } void DetailView::onMediaItemUpdated(const MediaItem &item) { if (m_currentItemId == item.id && m_core && !m_skipSilentRefresh) { QCoro::connect( executeSilentRefresh(QPointer(this), m_core, item.id), this, []() {}); } if (item.type == "Season" && !m_currentSeasonEpisodes.isEmpty()) { bool belongsToSeries = false; for (const MediaItem &season : m_seriesSeasons) { if (season.id == item.id) { belongsToSeries = true; break; } } if (belongsToSeries) { for (MediaItem &ep : m_currentSeasonEpisodes) { if (ep.parentIndexNumber == item.indexNumber) ep.userData.played = item.userData.played; } m_episodeWidget->setItems(m_currentSeasonEpisodes); } } if (item.type == "Episode" && !m_currentSeasonEpisodes.isEmpty() && !m_seriesSeasons.isEmpty()) { for (MediaItem &ep : m_currentSeasonEpisodes) { if (ep.id == item.id) { ep.userData.played = item.userData.played; break; } } const int seasonIndex = item.parentIndexNumber; if (seasonIndex >= 0) { for (MediaItem &season : m_seriesSeasons) { if (season.indexNumber == seasonIndex) { int total = 0, played = 0; for (const MediaItem &ep : m_currentSeasonEpisodes) { if (ep.parentIndexNumber == seasonIndex) { total++; if (ep.userData.played) played++; } } const bool allPlayed = (total > 0 && played == total); if (season.userData.played != allPlayed) { season.userData.played = allPlayed; m_seasonWidget->updateItem(season); } break; } } } } if (m_currentPlayableItem.id == item.id) { m_currentPlayableItem.userData.played = item.userData.played; m_actionWidget->setPlayedState(item.userData.played); } if (m_currentMediaItem.id == item.id) { m_currentMediaItem.userData.played = item.userData.played; m_actionWidget->setPlayedState(item.userData.played); } if (m_seasonWidget) m_seasonWidget->updateItem(item); if (m_episodeWidget) m_episodeWidget->updateItem(item); if (m_castWidget) m_castWidget->updateItem(item); if (m_additionalPartsWidget) m_additionalPartsWidget->updateItem(item); if (m_collectionWidget) m_collectionWidget->updateItem(item); if (m_similarWidget) m_similarWidget->updateItem(item); } void DetailView::beginOptimisticPlayedUpdate() { m_skipSilentRefresh = true; } void DetailView::endOptimisticPlayedUpdate() { m_skipSilentRefresh = false; } void DetailView::updateSeasonSwitcher(int currentIndex) { if (!m_seasonSwitcher) return; QSignalBlocker blocker(m_seasonSwitcher); m_seasonSwitcher->clear(); for (const MediaItem &season : m_seriesSeasons) m_seasonSwitcher->addItem(season.name, ""); if (!m_seriesSeasons.isEmpty()) { const int idx = qBound(0, currentIndex, m_seriesSeasons.size() - 1); m_seasonSwitcher->setCurrentIndex(idx); } m_seasonSwitcher->setVisible(m_seriesSeasons.size() > 1); m_seasonSwitcher->adjustSize(); if (m_episodeJumpEdit) { const int switcherHeight = qMax(m_seasonSwitcher->height(), m_seasonSwitcher->sizeHint().height()); m_episodeJumpEdit->setFixedHeight(switcherHeight); } updateEpisodeHeaderControlsVisibility(); } void DetailView::updateEpisodeHeaderControlsVisibility() { if (!m_episodeHeaderControls) return; const bool showSeasonSwitcher = m_seasonSwitcher && !m_seasonSwitcher->isHidden(); const bool showEpisodeJump = m_episodeJumpEdit && !m_episodeJumpEdit->isHidden(); m_episodeHeaderControls->setVisible(showSeasonSwitcher || showEpisodeJump); m_episodeHeaderControls->adjustSize(); m_episodeHeaderControls->updateGeometry(); } void DetailView::updateEpisodeJumpControl(int episodeCount) { if (!m_episodeJumpEdit) { return; } const int visibleCardCapacity = episodeGalleryVisibleCardCapacity(); const bool showJump = episodeCount > 0 && visibleCardCapacity > 0 && episodeCount > visibleCardCapacity; if (showJump) { int explicitMaxEpisodeNumber = 0; for (const MediaItem &episode : m_currentSeasonEpisodes) explicitMaxEpisodeNumber = qMax(explicitMaxEpisodeNumber, episode.indexNumber); const int maxEpisodeNumber = explicitMaxEpisodeNumber > 0 ? explicitMaxEpisodeNumber : episodeCount; const QString rangeText = tr("1-%1").arg(maxEpisodeNumber); m_episodeJumpEdit->setPlaceholderText(rangeText); m_episodeJumpEdit->setToolTip( tr("Jump to episode number (1-%1)").arg(maxEpisodeNumber)); if (m_episodeJumpValidator) m_episodeJumpValidator->setTop(maxEpisodeNumber); m_episodeJumpEdit->setMaxLength( qMax(1, QString::number(maxEpisodeNumber).length())); const int textWidth = m_episodeJumpEdit->fontMetrics().horizontalAdvance(rangeText); m_episodeJumpEdit->setFixedWidth(qBound(64, textWidth + 22, 92)); } else { m_episodeJumpEdit->clear(); m_episodeJumpEdit->setPlaceholderText(tr("Ep #")); m_episodeJumpEdit->setToolTip(tr("Jump to episode number")); if (m_episodeJumpValidator) m_episodeJumpValidator->setTop(9999); m_episodeJumpEdit->setMaxLength(4); m_episodeJumpEdit->setFixedWidth(64); } m_episodeJumpEdit->setVisible(showJump); updateEpisodeHeaderControlsVisibility(); } int DetailView::episodeGalleryVisibleCardCapacity() const { if (!m_episodeWidget || !m_episodeWidget->gallery()) return 0; QListView *listView = m_episodeWidget->gallery()->listView(); if (!listView || !listView->viewport()) return 0; int cardWidth = m_episodeTileWidth; if (cardWidth <= 0 && listView->model() && listView->model()->rowCount() > 0) { const QSize itemSize = listView->sizeHintForIndex(listView->model()->index(0, 0)); cardWidth = itemSize.width(); } if (cardWidth <= 0) return 0; const int viewportWidth = listView->viewport()->width(); if (viewportWidth <= 0) return 0; return qMax(1, viewportWidth / cardWidth); } MediaItem DetailView::episodeForJumpNumber(int episodeNumber) const { if (episodeNumber <= 0) return MediaItem{}; bool hasExplicitEpisodeNumbers = false; for (const MediaItem &episode : m_currentSeasonEpisodes) { if (episode.indexNumber > 0) hasExplicitEpisodeNumbers = true; if (episode.indexNumber == episodeNumber) return episode; } if (hasExplicitEpisodeNumbers) return MediaItem{}; const int row = episodeNumber - 1; if (row >= 0 && row < m_currentSeasonEpisodes.size()) return m_currentSeasonEpisodes.at(row); return MediaItem{}; } void DetailView::submitEpisodeJump() { if (!m_episodeJumpEdit || m_episodeJumpEdit->isHidden() || m_currentSeasonEpisodes.isEmpty()) { return; } const QString text = m_episodeJumpEdit->text().trimmed(); if (text.isEmpty()) return; bool ok = false; const int episodeNumber = text.toInt(&ok); if (!ok) return; const MediaItem targetEpisode = episodeForJumpNumber(episodeNumber); if (targetEpisode.id.isEmpty()) { qDebug() << "[DetailView] Episode jump target not found" << "seriesId=" << m_currentItemId << "seasonIndex=" << m_currentSeasonIndex << "episodeNumber=" << episodeNumber << "episodeCount=" << m_currentSeasonEpisodes.size(); ModernToast::showMessage(tr("Episode %1 not found").arg(episodeNumber), 1600); m_episodeJumpEdit->selectAll(); return; } qDebug() << "[DetailView] Episode jump" << "seriesId=" << m_currentItemId << "seasonIndex=" << m_currentSeasonIndex << "episodeNumber=" << episodeNumber << "episodeId=" << targetEpisode.id; m_episodeJumpEdit->clear(); markManualSeriesSelection(QStringLiteral("episode-jump")); QCoro::connect(applySeriesPlayableItem(targetEpisode, true), this, []() {}); } QCoro::Task DetailView::loadEpisodesForSeason(int idx, QString highlightEpisodeId, bool scrollToHighlight, bool manualSelection) { if (idx < 0 || idx >= m_seriesSeasons.size() || !m_episodeWidget) co_return; m_currentSeasonIndex = idx; const QString seasonId = m_seriesSeasons[idx].id; if (seasonId.isEmpty()) co_return; if (manualSelection) markManualSeriesSelection(QStringLiteral("season-selection")); rememberSeriesSelection(MediaItem{}, QStringLiteral("season-selection"), !manualSelection); bool showedCachedEpisodes = false; QList shownCachedEpisodes; if (m_hasCachedSeasonEpisodes && m_cachedSeasonIndex == idx && m_cachedSeasonId == seasonId && episodesBelongToSeason(m_cachedSeasonEpisodes, m_seriesSeasons[idx])) { shownCachedEpisodes = m_cachedSeasonEpisodes; if (!m_appliedCachedSeasonEpisodesToUi) { m_currentSeasonEpisodes = shownCachedEpisodes; applyReservedSectionItems(m_episodeSectionReserveWidget, m_episodeWidget, shownCachedEpisodes); updateEpisodeJumpControl(shownCachedEpisodes.size()); if (!highlightEpisodeId.isEmpty()) { m_episodeWidget->gallery()->setHighlightedItemId(highlightEpisodeId); if (scrollToHighlight) { m_episodeWidget->gallery()->scrollToItemId(highlightEpisodeId); } } m_appliedCachedSeasonEpisodesToUi = true; } showedCachedEpisodes = true; qDebug() << "[DetailView][cache-ui] Applied cached season episodes" << "seriesId=" << m_currentItemId << "seasonId=" << seasonId << "seasonIndex=" << idx << "episodeCount=" << shownCachedEpisodes.size(); } else { m_currentSeasonEpisodes.clear(); updateEpisodeJumpControl(0); } if (!m_core || !m_core->mediaService()) co_return; QPointer safeThis(this); const QString seriesId = m_currentItemId; QEmbyCore *core = m_core; try { qDebug() << "[DetailView][network] Fetch season episodes" << "seriesId=" << seriesId << "seasonId=" << seasonId << "seasonIndex=" << idx; QList episodes = co_await core->mediaService()->getEpisodes(seriesId, seasonId); if (!safeThis || safeThis->m_currentItemId != seriesId || safeThis->m_currentSeasonIndex != idx) co_return; bool episodesUnchanged = false; if (showedCachedEpisodes) { episodesUnchanged = co_await mediaItemListsEqualAsync(shownCachedEpisodes, episodes); if (!safeThis || safeThis->m_currentItemId != seriesId || safeThis->m_currentSeasonIndex != idx) co_return; } safeThis->m_currentSeasonEpisodes = episodes; if (!safeThis->m_hasManualSeriesSelection) { safeThis->m_cachedSeasonEpisodes = episodes; safeThis->m_cachedSeasonIndex = idx; safeThis->m_cachedSeasonId = seasonId; safeThis->m_hasCachedSeasonEpisodes = true; } else { qDebug() << "[DetailView] Skip persisting manually selected season " "episodes" << "seriesId=" << seriesId << "seasonId=" << seasonId << "seasonIndex=" << idx; } if (!manualSelection && !highlightEpisodeId.isEmpty() && safeThis->m_currentPlayableItem.id != highlightEpisodeId) { for (const MediaItem &episode : episodes) { if (episode.id == highlightEpisodeId) { safeThis->applySeriesPlayableItemToUi(episode); break; } } } if (!episodesUnchanged) { applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, safeThis->m_episodeWidget, episodes); } safeThis->updateEpisodeJumpControl(episodes.size()); qDebug() << "[DetailView][network] Loaded season episodes" << "seriesId=" << seriesId << "seasonId=" << seasonId << "seasonIndex=" << idx << "episodeCount=" << episodes.size() << "fromCache=" << showedCachedEpisodes << "unchanged=" << episodesUnchanged << "visibleCardCapacity=" << safeThis->episodeGalleryVisibleCardCapacity() << "jumpVisible=" << (safeThis->m_episodeJumpEdit && !safeThis->m_episodeJumpEdit->isHidden()); if (!highlightEpisodeId.isEmpty()) { safeThis->m_episodeWidget->gallery()->setHighlightedItemId( highlightEpisodeId); if (scrollToHighlight) { safeThis->m_episodeWidget->gallery()->scrollToItemId( highlightEpisodeId); } } safeThis->persistDetailCacheSnapshot(QStringLiteral("episodes")); } catch (...) { if (safeThis && safeThis->m_currentItemId == seriesId && safeThis->m_currentSeasonIndex == idx) { if (!showedCachedEpisodes) { safeThis->m_currentSeasonEpisodes.clear(); safeThis->updateEpisodeJumpControl(0); applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, safeThis->m_episodeWidget, {}); } qDebug() << "[DetailView] Failed to load season episodes" << "seriesId=" << seriesId << "seasonId=" << seasonId << "seasonIndex=" << idx << "keptCache=" << showedCachedEpisodes; } } } QCoro::Task DetailView::switchToSeason(int idx, bool manualSelection) { co_await loadEpisodesForSeason(idx, QString(), false, manualSelection); }