Merge pull request #1290 from skoruppa/fixes-2

Collection translations, TMDB enrichment for pause overlay & collections, CW fixes
This commit is contained in:
skoruppa 2026-04-09 01:55:59 +02:00 committed by GitHub
commit 664c98f84c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 768 additions and 178 deletions

View file

@ -100,3 +100,10 @@
# Native code reflects into multiple classes/methods under is.xyz.mpv,
# so keep the whole package to avoid JNI lookup crashes after R8.
-keep class is.xyz.mpv.** { *; }
# ── Missing class stubs (referenced by cloudstream3 / jsoup / newpipe) ────────
-dontwarn org.mozilla.javascript.**
-dontwarn com.google.re2j.**
-dontwarn javax.script.**
-dontwarn okhttp3.internal.sse.**
-dontwarn org.jsoup.helper.Re2jRegex

View file

@ -871,9 +871,9 @@ object AddonWebPage {
</div>
<div class="tabs">
<button class="tab active" type="button" onclick="switchTab('addons')">Addons</button>
<button class="tab" type="button" onclick="switchTab('catalogs')">Home Layout</button>
<button class="tab" type="button" onclick="switchTab('collections')">Collections</button>
<button class="tab active" type="button" onclick="switchTab('addons')">${context.getString(R.string.web_tab_addons)}</button>
<button class="tab" type="button" onclick="switchTab('catalogs')">${context.getString(R.string.web_tab_home_layout)}</button>
<button class="tab" type="button" onclick="switchTab('collections')">${context.getString(R.string.web_tab_collections)}</button>
</div>
<div class="tab-content active" id="tab-addons">
@ -895,8 +895,8 @@ object AddonWebPage {
<div class="section-block">
<div class="section-label">${context.getString(R.string.web_home_catalogs)}</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="enableAllCatalogs()" style="flex:1">Enable All</button>
<button class="btn" onclick="disableAllCatalogs()" style="flex:1">Disable All</button>
<button class="btn" onclick="enableAllCatalogs()" style="flex:1">${context.getString(R.string.web_btn_enable_all)}</button>
<button class="btn" onclick="disableAllCatalogs()" style="flex:1">${context.getString(R.string.web_btn_disable_all)}</button>
</div>
<ul class="addon-list" id="catalogList"></ul>
<div class="empty-state" id="catalogEmptyState">${context.getString(R.string.web_no_catalogs)}</div>
@ -905,28 +905,28 @@ object AddonWebPage {
<div class="tab-content" id="tab-collections">
<div class="section-block">
<div class="section-label">Collections</div>
<div class="section-label">${context.getString(R.string.web_tab_collections)}</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="enableAllCollections()" style="flex:1">Show All</button>
<button class="btn" onclick="disableAllCollections()" style="flex:1">Hide All</button>
<button class="btn" onclick="enableAllCollections()" style="flex:1">${context.getString(R.string.web_btn_show_all)}</button>
<button class="btn" onclick="disableAllCollections()" style="flex:1">${context.getString(R.string.web_btn_hide_all)}</button>
</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="addCollection()" style="flex:1">+ New Collection</button>
<button class="btn" onclick="exportCollections()" style="flex:1">Export</button>
<button class="btn" onclick="showImportModal()" style="flex:1">Import</button>
<button class="btn" onclick="addCollection()" style="flex:1">${context.getString(R.string.web_btn_new_collection)}</button>
<button class="btn" onclick="exportCollections()" style="flex:1">${context.getString(R.string.web_btn_export)}</button>
<button class="btn" onclick="showImportModal()" style="flex:1">${context.getString(R.string.web_btn_import)}</button>
</div>
<div id="collectionsList"></div>
<div class="empty-state" id="collectionsEmptyState">No collections yet</div>
<div class="empty-state" id="collectionsEmptyState">${context.getString(R.string.web_no_collections)}</div>
</div>
</div>
<div class="import-overlay" id="importOverlay">
<div class="import-modal">
<div style="font-size:1.1rem;font-weight:700;margin-bottom:1rem">Import Collections</div>
<div style="font-size:1.1rem;font-weight:700;margin-bottom:1rem">${context.getString(R.string.web_import_collections_title)}</div>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button class="btn import-tab-btn active" onclick="switchImportTab('paste')">Paste</button>
<button class="btn import-tab-btn" onclick="switchImportTab('file')">File</button>
<button class="btn import-tab-btn" onclick="switchImportTab('url')">URL</button>
<button class="btn import-tab-btn active" onclick="switchImportTab('paste')">${context.getString(R.string.web_import_tab_paste)}</button>
<button class="btn import-tab-btn" onclick="switchImportTab('file')">${context.getString(R.string.web_import_tab_file)}</button>
<button class="btn import-tab-btn" onclick="switchImportTab('url')">${context.getString(R.string.web_import_tab_url)}</button>
</div>
<div id="import-tab-paste" class="import-tab active">
<textarea id="importJsonInput" placeholder="Paste collections JSON here..." style="width:100%;min-height:120px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:0.75rem;color:#fff;font-family:monospace;font-size:0.8rem;resize:vertical"></textarea>
@ -973,6 +973,39 @@ var pollTimer = null;
var pollStartTime = 0;
var POLL_TIMEOUT = 120000;
var POLL_INTERVAL = 1500;
var i18n = {
newCollection: '${context.getString(R.string.collections_new).replace("'", "\\'")}',
backdrop: '${context.getString(R.string.collections_editor_backdrop).replace("'", "\\'")}',
pinAbove: '${context.getString(R.string.collections_editor_pin_above).replace("'", "\\'")}',
focusGlow: '${context.getString(R.string.collections_editor_focus_glow).replace("'", "\\'")}',
viewMode: '${context.getString(R.string.collections_editor_view_mode).replace("'", "\\'")}',
tabs: '${context.getString(R.string.collections_editor_view_mode_tabs).replace("'", "\\'")}',
rows: '${context.getString(R.string.collections_editor_view_mode_rows).replace("'", "\\'")}',
followHome: '${context.getString(R.string.collections_editor_view_mode_follow).replace("'", "\\'")}',
showAllTab: '${context.getString(R.string.collections_editor_show_all_tab).replace("'", "\\'")}',
cover: '${context.getString(R.string.collections_editor_cover).replace("'", "\\'")}',
coverNone: '${context.getString(R.string.collections_editor_cover_none).replace("'", "\\'")}',
coverEmoji: '${context.getString(R.string.collections_editor_cover_emoji).replace("'", "\\'")}',
coverImage: '${context.getString(R.string.collections_editor_cover_image_url).replace("'", "\\'")}',
focusGif: '${context.getString(R.string.collections_editor_focus_gif).replace("'", "\\'")}',
playGif: '${context.getString(R.string.collections_editor_play_gif).replace("'", "\\'")}',
tileShape: '${context.getString(R.string.collections_editor_tile_shape).replace("'", "\\'")}',
hideTitle: '${context.getString(R.string.collections_editor_hide_title).replace("'", "\\'")}',
catalogs: '${context.getString(R.string.collections_editor_catalogs).replace("'", "\\'")}',
addCatalog: '${context.getString(R.string.collections_editor_add_catalog).replace("'", "\\'")}',
addFolder: '${context.getString(R.string.collections_editor_add_folder).replace("'", "\\'")}',
folders: '${context.getString(R.string.collections_editor_folders).replace("'", "\\'")}',
hidden: '${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}',
display: '${context.getString(R.string.collections_editor_display).replace("'", "\\'")}',
shape: '${context.getString(R.string.collections_editor_tile_shape).replace("'", "\\'")}',
shapePoster: '${context.getString(R.string.collections_editor_shape_poster).replace("'", "\\'")}',
shapeWide: '${context.getString(R.string.collections_editor_shape_wide).replace("'", "\\'")}',
shapeSquare: '${context.getString(R.string.collections_editor_shape_square).replace("'", "\\'")}',
tapToPickEmoji: '${context.getString(R.string.collections_editor_cover_emoji).replace("'", "\\'")}',
added: '${context.getString(R.string.web_btn_add).replace("'", "\\'")}',
add: '+ ${context.getString(R.string.web_btn_add).replace("'", "\\'")}'
};
var connectionLost = false;
var consecutiveErrors = 0;
var activeTab = 'addons';
@ -1247,7 +1280,7 @@ function renderCatalogs() {
'</div>' +
'<div class="catalog-info">' +
'<div class="catalog-name">' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) +
(isCollection ? '<span class="badge-collection">Collection</span>' : '') +
(isCollection ? '<span class="badge-collection">${context.getString(R.string.web_badge_collection).replace("'", "\\'")}</span>' : '') +
(catalog.isDisabled ? '<span class="badge-disabled">${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}</span>' : '') +
'</div>' +
'<div class="catalog-meta">' + escapeHtml(catalog.addonName) + '</div>' +
@ -1577,7 +1610,7 @@ function isCollectionDisabled(ci) {
}
function addCollection() {
collections.push({ id: generateId(), title: 'New Collection', backdropImageUrl: null, pinToTop: false, focusGlowEnabled: true, viewMode: 'TABBED_GRID', showAllTab: true, folders: [] });
collections.push({ id: generateId(), title: i18n.newCollection, backdropImageUrl: null, pinToTop: false, focusGlowEnabled: true, viewMode: 'TABBED_GRID', showAllTab: true, folders: [] });
expandedCollection = collections.length - 1;
expandedFolder = null;
renderCollections();
@ -1782,7 +1815,7 @@ function renderCollections() {
'<div class="collection-header collapse-header" onclick="toggleCollectionExpand(' + ci + ')">' +
'<span class="collapse-arrow' + (isExpanded ? ' open' : '') + '">&#9654;</span>' +
'<input class="collection-title-input" value="' + escapeAttr(col.title) + '" onchange="updateCollectionTitle(' + ci + ',this.value);updateSaveButtonState()" onclick="event.stopPropagation()" placeholder="Collection name">' +
(disabled ? '<span class="badge-collection-disabled">Hidden</span>' : '') +
(disabled ? '<span class="badge-collection-disabled">' + i18n.hidden + '</span>' : '') +
(errors.length > 0 ? '<span style="font-size:0.6rem;font-weight:700;color:rgba(255,180,60,0.9);background:rgba(255,180,60,0.12);padding:0.2rem 0.5rem;border-radius:100px;flex-shrink:0">' + errors.length + ' issue' + (errors.length > 1 ? 's' : '') + '</span>' : '') +
'<div class="col-actions" onclick="event.stopPropagation()">' +
'<button class="btn-order" onclick="moveCollection(' + ci + ',-1)"' + (ci === 0 ? ' disabled' : '') + '>' +
@ -1802,7 +1835,7 @@ function renderCollections() {
if (!isExpanded) {
card.innerHTML = headerHtml +
'<div class="folder-summary">' + folderCount + ' folder' + (folderCount !== 1 ? 's' : '') + '</div>';
'<div class="folder-summary">' + i18n.folders + ': ' + folderCount + '</div>';
container.appendChild(card);
return;
}
@ -1811,12 +1844,12 @@ function renderCollections() {
var settingsHtml =
'<div class="col-settings">' +
'<div class="col-setting-row">' +
'<span class="col-meta-label">Backdrop</span>' +
'<span class="col-meta-label">' + i18n.backdrop + '</span>' +
'<img id="col-backdrop-preview-' + ci + '" src="' + escapeAttr(col.backdropImageUrl || '') + '" style="' + (col.backdropImageUrl ? '' : 'display:none') + '" onerror="this.style.display=\'none\'">' +
'<input type="url" placeholder="Image URL (optional)" value="' + escapeAttr(col.backdropImageUrl || '') + '" oninput="updateCollectionBackdrop(' + ci + ',this.value)">' +
'</div>' +
'<div class="col-setting-row">' +
'<span class="toggle-label">Pin above catalogs</span>' +
'<span class="toggle-label">' + i18n.pinAbove + '</span>' +
'<label class="toggle-switch" onclick="event.stopPropagation()">' +
'<input type="checkbox"' + (col.pinToTop ? ' checked' : '') + ' onchange="updateCollectionPinToTop(' + ci + ',this.checked)">' +
'<span class="toggle-track"></span>' +
@ -1824,7 +1857,7 @@ function renderCollections() {
'</label>' +
'</div>' +
'<div class="col-setting-row">' +
'<span class="toggle-label">Focus glow on cards</span>' +
'<span class="toggle-label">' + i18n.focusGlow + '</span>' +
'<label class="toggle-switch" onclick="event.stopPropagation()">' +
'<input type="checkbox"' + (col.focusGlowEnabled !== false ? ' checked' : '') + ' onchange="updateCollectionFocusGlow(' + ci + ',this.checked)">' +
'<span class="toggle-track"></span>' +
@ -1832,16 +1865,16 @@ function renderCollections() {
'</label>' +
'</div>' +
'<div class="col-setting-row">' +
'<span class="col-meta-label">View Mode</span>' +
'<span class="col-meta-label">' + i18n.viewMode + '</span>' +
'<div class="cover-mode-picker">' +
'<button class="cover-mode-btn' + ((col.viewMode === 'TABBED_GRID' || !col.viewMode) ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'TABBED_GRID\')">Tabs</button>' +
'<button class="cover-mode-btn' + (col.viewMode === 'ROWS' ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'ROWS\')">Rows</button>' +
'<button class="cover-mode-btn' + (col.viewMode === 'FOLLOW_LAYOUT' ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'FOLLOW_LAYOUT\')">Follow Home</button>' +
'<button class="cover-mode-btn' + ((col.viewMode === 'TABBED_GRID' || !col.viewMode) ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'TABBED_GRID\')">' + i18n.tabs + '</button>' +
'<button class="cover-mode-btn' + (col.viewMode === 'ROWS' ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'ROWS\')">' + i18n.rows + '</button>' +
'<button class="cover-mode-btn' + (col.viewMode === 'FOLLOW_LAYOUT' ? ' active' : '') + '" onclick="updateCollectionViewMode(' + ci + ',\'FOLLOW_LAYOUT\')">' + i18n.followHome + '</button>' +
'</div>' +
'</div>' +
((col.viewMode === 'TABBED_GRID' || !col.viewMode) ?
'<div class="col-setting-row">' +
'<span class="toggle-label">Show "All" tab</span>' +
'<span class="toggle-label">' + i18n.showAllTab + '</span>' +
'<label class="toggle-switch" onclick="event.stopPropagation()">' +
'<input type="checkbox"' + (col.showAllTab !== false ? ' checked' : '') + ' onchange="updateCollectionShowAllTab(' + ci + ',this.checked)">' +
'<span class="toggle-track"></span>' +
@ -1927,12 +1960,12 @@ function renderCollections() {
(isFolderExpanded ?
'<div class="folder-settings">' +
'<div class="folder-settings-group">' +
'<div class="folder-settings-group-label">Cover</div>' +
'<div class="folder-settings-group-label">' + i18n.cover + '</div>' +
'<div class="folder-setting-item">' +
'<div class="cover-mode-picker">' +
'<button class="cover-mode-btn' + (coverMode === 'none' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'none\')">None</button>' +
'<button class="cover-mode-btn' + (coverMode === 'emoji' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'emoji\')">Emoji</button>' +
'<button class="cover-mode-btn' + (coverMode === 'image' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'image\')">Image</button>' +
'<button class="cover-mode-btn' + (coverMode === 'none' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'none\')">' + i18n.coverNone + '</button>' +
'<button class="cover-mode-btn' + (coverMode === 'emoji' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'emoji\')">' + i18n.coverEmoji + '</button>' +
'<button class="cover-mode-btn' + (coverMode === 'image' ? ' active' : '') + '" onclick="setFolderCoverMode(' + ci + ',' + fi + ',\'image\')">' + i18n.coverImage + '</button>' +
'</div>' +
'</div>' +
(coverMode === 'emoji' ?
@ -1940,7 +1973,7 @@ function renderCollections() {
'<button class="emoji-picker-btn" onclick="toggleEmojiPicker(' + ci + ',' + fi + ')">' +
(folder.coverEmoji ? escapeHtml(folder.coverEmoji) : '😀') +
'</button>' +
'<span style="font-size:0.78rem;color:rgba(255,255,255,0.3);flex:1">Tap to pick emoji</span>' +
'<span style="font-size:0.78rem;color:rgba(255,255,255,0.3);flex:1">' + i18n.tapToPickEmoji + '</span>' +
'</div>' +
'<div id="emoji-grid-' + ci + '-' + fi + '" class="emoji-grid-wrap" style="margin:0 0.75rem 0.5rem">' +
'<input class="emoji-grid-search" placeholder="Search emoji..." oninput="filterEmoji(' + ci + ',' + fi + ',this.value)">' +
@ -1955,7 +1988,7 @@ function renderCollections() {
'<input type="url" placeholder="Focused GIF URL (optional)" value="' + escapeAttr(folder.focusGifUrl || '') + '" oninput="updateFolderFocusGifUrl(' + ci + ',' + fi + ',this.value)">' +
'</div>' +
'<div class="folder-setting-item">' +
'<span class="toggle-label">Play GIF on focus</span>' +
'<span class="toggle-label">' + i18n.playGif + '</span>' +
'<label class="toggle-switch">' +
'<input type="checkbox"' + (folder.focusGifEnabled !== false ? ' checked' : '') + ' onchange="updateFolderFocusGifEnabled(' + ci + ',' + fi + ',this.checked)">' +
'<span class="toggle-track"></span>' +
@ -1964,17 +1997,17 @@ function renderCollections() {
'</div>' +
'</div>' +
'<div class="folder-settings-group">' +
'<div class="folder-settings-group-label">Display</div>' +
'<div class="folder-settings-group-label">' + i18n.display + '</div>' +
'<div class="folder-setting-item">' +
'<span class="folder-setting-label">Shape</span>' +
'<span class="folder-setting-label">' + i18n.shape + '</span>' +
'<select onchange="updateFolderTileShape(' + ci + ',' + fi + ',this.value)">' +
'<option value="POSTER"' + (folder.tileShape === 'POSTER' ? ' selected' : '') + '>Poster</option>' +
'<option value="LANDSCAPE"' + (folder.tileShape === 'LANDSCAPE' ? ' selected' : '') + '>Landscape</option>' +
'<option value="SQUARE"' + ((folder.tileShape === 'SQUARE' || !folder.tileShape) ? ' selected' : '') + '>Square</option>' +
'<option value="POSTER"' + (folder.tileShape === 'POSTER' ? ' selected' : '') + '>' + i18n.shapePoster + '</option>' +
'<option value="LANDSCAPE"' + (folder.tileShape === 'LANDSCAPE' ? ' selected' : '') + '>' + i18n.shapeWide + '</option>' +
'<option value="SQUARE"' + ((folder.tileShape === 'SQUARE' || !folder.tileShape) ? ' selected' : '') + '>' + i18n.shapeSquare + '</option>' +
'</select>' +
'</div>' +
'<div class="folder-setting-item">' +
'<span class="toggle-label">Hide title</span>' +
'<span class="toggle-label">' + i18n.hideTitle + '</span>' +
'<label class="toggle-switch">' +
'<input type="checkbox" id="ht-' + ci + '-' + fi + '"' + (folder.hideTitle ? ' checked' : '') + ' onchange="updateFolderHideTitle(' + ci + ',' + fi + ',this.checked)">' +
'<span class="toggle-track"></span>' +
@ -1983,7 +2016,7 @@ function renderCollections() {
'</div>' +
'</div>' +
'<div class="folder-settings-group">' +
'<div class="folder-settings-group-label">Active Sources</div>' +
'<div class="folder-settings-group-label">' + i18n.catalogs + '</div>' +
'<div style="padding:0.5rem 0.75rem">' +
'<input class="source-search-input" placeholder="Filter active sources..." oninput="filterActiveSources(' + ci + ',' + fi + ',this.value)" id="active-src-search-' + ci + '-' + fi + '">' +
'<div id="active-src-list-' + ci + '-' + fi + '" style="max-height:180px;overflow-y:auto;border:1px solid rgba(255,255,255,0.05);border-radius:8px;margin-top:0.25rem">' +
@ -1993,7 +2026,7 @@ function renderCollections() {
'</div>' +
'</div>' +
'<div class="folder-settings-group" style="margin-top:0.5rem">' +
'<div class="folder-settings-group-label">Add Sources</div>' +
'<div class="folder-settings-group-label">' + i18n.addCatalog + '</div>' +
'<div style="padding:0.5rem 0.75rem">' +
'<input class="source-search-input" placeholder="Search catalogs..." oninput="filterCatalogSources(' + ci + ',' + fi + ',this.value)" id="src-search-' + ci + '-' + fi + '">' +
'<div id="src-list-' + ci + '-' + fi + '" style="max-height:200px;overflow-y:auto;border:1px solid rgba(255,255,255,0.05);border-radius:8px;margin-top:0.25rem">' + sourceListHtml + '</div>' +
@ -2005,7 +2038,7 @@ function renderCollections() {
});
card.innerHTML = headerHtml + settingsHtml + foldersHtml +
'<div style="padding:0.5rem 1rem 0.875rem"><button class="btn" onclick="addFolder(' + ci + ')" style="width:100%;padding:0.6rem;font-size:0.8rem">+ Add Folder</button></div>';
'<div style="padding:0.5rem 1rem 0.875rem"><button class="btn" onclick="addFolder(' + ci + ')" style="width:100%;padding:0.6rem;font-size:0.8rem">+ ' + i18n.addFolder + '</button></div>';
container.appendChild(card);
});

View file

@ -286,7 +286,11 @@ class WatchProgressSyncService @Inject constructor(
}
}
.groupBy { it.contentId }
.mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } }
.mapValues { (_, episodes) -> episodes.maxWithOrNull(
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
) }
latestEpisodeByContent.forEach { (contentId, latestEpisode) ->
val latest = latestEpisode ?: return@forEach

View file

@ -30,6 +30,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -298,8 +301,7 @@ class TraktProgressService @Inject constructor(
val optimistic = progress.copy(
progressPercent = derivedPercent,
source = WatchProgress.SOURCE_TRAKT_PLAYBACK,
lastWatched = now
source = WatchProgress.SOURCE_TRAKT_PLAYBACK
)
optimisticProgress.update { current ->
@ -369,8 +371,7 @@ class TraktProgressService @Inject constructor(
fun observeWatchedShowSeeds(): Flow<List<WatchProgress>> {
return combine(
watchedShowSeedsState
.onStart { emit(getWatchedShowSeedsSnapshot(forceRefresh = false)) },
watchedShowSeedsState,
hiddenProgressShowIds
) { seeds, _ ->
seeds.filter { !isShowHiddenFromProgress(it.contentId) }
@ -796,17 +797,21 @@ class TraktProgressService @Inject constructor(
getWatchedMoviesSnapshot(forceRefresh = true)
}
if (force && hasLoadedWatchedShowSeeds) {
getWatchedShowSeedsSnapshot(forceRefresh = true)
}
val needSeedsRefresh = force ||
(watchedShowSeedsStale && hasLoadedWatchedShowSeeds)
coroutineScope {
val progressDeferred = async { fetchAllProgressSnapshot(force = force) }
val seedsDeferred = if (needSeedsRefresh) {
async { getWatchedShowSeedsSnapshot(forceRefresh = true) }
} else null
val snapshot = fetchAllProgressSnapshot(force = force)
remoteProgress.value = snapshot
hasLoadedRemoteProgress.value = true
reconcileOptimistic(snapshot)
hydrateMetadata(snapshot)
if (!force && watchedShowSeedsStale && hasLoadedWatchedShowSeeds) {
getWatchedShowSeedsSnapshot(forceRefresh = true)
val snapshot = progressDeferred.await()
seedsDeferred?.await()
remoteProgress.value = snapshot
hasLoadedRemoteProgress.value = true
reconcileOptimistic(snapshot)
hydrateMetadata(snapshot)
}
}
@ -1357,13 +1362,22 @@ class TraktProgressService @Inject constructor(
}
private suspend fun fetchAllProgressSnapshot(force: Boolean = false): List<WatchProgress> {
val recentCompletedEpisodes = fetchRecentEpisodeHistorySnapshot()
val playbackStartAt = recentWatchWindowMs()?.let { windowMs ->
toTraktUtcDateTime(System.currentTimeMillis() - windowMs)
}
val inProgressMovies = getPlayback("movies", force = force, startAt = playbackStartAt).mapNotNull { mapPlaybackMovie(it) }
val inProgressEpisodes = getPlayback("episodes", force = force, startAt = playbackStartAt)
.mapNotNull { mapPlaybackEpisode(it, applyAddonRemap = true) }
val (recentCompletedEpisodes, inProgressMovies, inProgressEpisodes) = coroutineScope {
val historyDeferred = async { fetchRecentEpisodeHistorySnapshot() }
val moviesDeferred = async {
getPlayback("movies", force = force, startAt = playbackStartAt)
.mapNotNull { mapPlaybackMovie(it) }
}
val episodesDeferred = async {
getPlayback("episodes", force = force, startAt = playbackStartAt)
.mapNotNull { mapPlaybackEpisode(it, applyAddonRemap = true) }
}
Triple(historyDeferred.await(), moviesDeferred.await(), episodesDeferred.await())
}
val mergedByKey = linkedMapOf<String, WatchProgress>()
@ -1390,7 +1404,8 @@ class TraktProgressService @Inject constructor(
"${progress.contentId}_s${progress.season}e${progress.episode}" in completedEpisodeKeys
}
return mergedByKey.values.sortedByDescending { it.lastWatched }
val finalSnapshot = mergedByKey.values.sortedByDescending { it.lastWatched }
return finalSnapshot
}
private suspend fun fetchRecentEpisodeHistorySnapshot(): List<WatchProgress> {

View file

@ -345,15 +345,42 @@ class WatchProgressRepositoryImpl @Inject constructor(
mergeNextUpSeeds(canonicalSeeds, optimisticSeeds)
}
} else {
watchProgressPreferences.allProgress.map { items ->
items.filter { progress ->
progress.isCompleted() &&
progress.contentType.equals("series", ignoreCase = true) &&
progress.season != null &&
progress.episode != null &&
progress.season != 0 &&
!isMalformedNextUpSeedContentId(progress.contentId)
}
// Use watched items (fully synced with pagination) to build seeds
// instead of watch progress (limited to 1000 entries).
watchedItemsPreferences.allItems.map { items ->
items
.filter { item ->
(item.contentType.equals("series", ignoreCase = true) ||
item.contentType.equals("tv", ignoreCase = true)) &&
item.season != null &&
item.episode != null &&
item.season != 0 &&
!isMalformedNextUpSeedContentId(item.contentId)
}
.groupBy { it.contentId }
.mapNotNull { (_, episodes) ->
val latest = episodes.maxWithOrNull(
compareBy<WatchedItem> { it.watchedAt }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
) ?: return@mapNotNull null
WatchProgress(
contentId = latest.contentId,
contentType = latest.contentType,
name = latest.title,
poster = null,
backdrop = null,
logo = null,
videoId = latest.contentId,
season = latest.season,
episode = latest.episode,
episodeTitle = null,
position = 1L,
duration = 1L,
lastWatched = latest.watchedAt,
progressPercent = 100f
)
}
}
}
}

View file

@ -623,12 +623,12 @@ private fun CollectionsEntryCard(onClick: () -> Unit) {
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Collections",
text = stringResource(R.string.collections_card_title),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = NuvioColors.TextPrimary
)
Text(
text = "Group catalogs into folders on your home screen",
text = stringResource(R.string.collections_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)

View file

@ -71,6 +71,8 @@ import com.nuvio.tv.domain.model.FolderViewMode
import com.nuvio.tv.domain.model.PosterShape
import com.nuvio.tv.ui.components.LoadingIndicator
import com.nuvio.tv.ui.theme.NuvioColors
import com.nuvio.tv.R
import androidx.compose.ui.res.stringResource
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -215,7 +217,7 @@ fun CollectionEditorScreen(
) {
item(key = "header") {
Text(
text = if (uiState.isNew) "New Collection" else "Edit Collection",
text = if (uiState.isNew) stringResource(R.string.collections_new) else stringResource(R.string.collections_editor_edit_collection),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
@ -224,7 +226,7 @@ fun CollectionEditorScreen(
item(key = "title") {
Text(
text = "Row Title",
text = stringResource(R.string.collections_editor_row_title),
style = MaterialTheme.typography.labelLarge,
color = NuvioColors.TextSecondary
)
@ -238,11 +240,11 @@ fun CollectionEditorScreen(
value = uiState.title,
onValueChange = { viewModel.setTitle(it) },
modifier = Modifier.weight(1f),
placeholder = "Collection name"
placeholder = stringResource(R.string.collections_editor_placeholder_name)
)
val canSaveCollection = uiState.title.isNotBlank() && uiState.folders.isNotEmpty()
NuvioButton(onClick = { viewModel.save { onBack() } }, enabled = canSaveCollection) {
Text("Save")
Text(stringResource(R.string.collections_editor_save))
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -250,7 +252,7 @@ fun CollectionEditorScreen(
item(key = "backdrop") {
Text(
text = "Backdrop Image",
text = stringResource(R.string.collections_editor_backdrop),
style = MaterialTheme.typography.labelLarge,
color = NuvioColors.TextSecondary
)
@ -259,7 +261,7 @@ fun CollectionEditorScreen(
value = uiState.backdropImageUrl,
onValueChange = { viewModel.setBackdropImageUrl(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = "Backdrop image URL (optional)"
placeholder = stringResource(R.string.collections_editor_placeholder_backdrop)
)
Spacer(modifier = Modifier.height(16.dp))
}
@ -290,13 +292,13 @@ fun CollectionEditorScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Pin Above Catalogs",
text = stringResource(R.string.collections_editor_pin_above),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Show this collection above all regular home catalogs. Multiple pinned collections follow collection creation order.",
text = stringResource(R.string.collections_editor_pin_above_desc),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)
@ -343,13 +345,13 @@ fun CollectionEditorScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Focus Glow",
text = stringResource(R.string.collections_editor_focus_glow),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Use the native TV glow on this collection's home-screen custom catalog cards",
text = stringResource(R.string.collections_editor_focus_glow_desc),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)
@ -372,7 +374,7 @@ fun CollectionEditorScreen(
item(key = "view_mode") {
Text(
text = "View Mode",
text = stringResource(R.string.collections_editor_view_mode),
style = MaterialTheme.typography.labelLarge,
color = NuvioColors.TextSecondary
)
@ -382,9 +384,9 @@ fun CollectionEditorScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val viewModes = listOf(
FolderViewMode.TABBED_GRID to "Tabs",
FolderViewMode.ROWS to "Rows",
FolderViewMode.FOLLOW_LAYOUT to "Follow Home Layout"
FolderViewMode.TABBED_GRID to stringResource(R.string.collections_editor_view_mode_tabs),
FolderViewMode.ROWS to stringResource(R.string.collections_editor_view_mode_rows),
FolderViewMode.FOLLOW_LAYOUT to stringResource(R.string.collections_editor_view_mode_follow)
)
viewModes.forEach { (mode, label) ->
val isSelected = uiState.viewMode == mode
@ -442,13 +444,13 @@ fun CollectionEditorScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Show \"All\" Tab",
text = stringResource(R.string.collections_editor_show_all_tab),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Combine all catalogs into one tab",
text = stringResource(R.string.collections_editor_show_all_tab_desc),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)
@ -477,7 +479,7 @@ fun CollectionEditorScreen(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Folders",
text = stringResource(R.string.collections_editor_folders),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
@ -510,9 +512,9 @@ fun CollectionEditorScreen(
item(key = "add_folder") {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 4.dp)) {
NuvioButton(onClick = { viewModel.addFolder() }) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add")
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.cd_add))
Spacer(modifier = Modifier.width(8.dp))
Text("Add Folder")
Text(stringResource(R.string.collections_editor_add_folder))
}
}
}
@ -551,7 +553,7 @@ private fun FolderListItem(
overflow = TextOverflow.Ellipsis
)
Text(
text = "${folder.tileShape.name.lowercase().replaceFirstChar { it.uppercase() }} - ${folder.catalogSources.size} catalog${if (folder.catalogSources.size != 1) "s" else ""}",
text = "${folder.tileShape.name.lowercase().replaceFirstChar { it.uppercase() }} - ${stringResource(R.string.collections_editor_catalog_count, folder.catalogSources.size)}",
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
@ -559,16 +561,16 @@ private fun FolderListItem(
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
NuvioButton(onClick = onMoveUp) {
Icon(Icons.Default.KeyboardArrowUp, "Move Up", tint = if (!isFirst) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
Icon(Icons.Default.KeyboardArrowUp, stringResource(R.string.cd_move_up), tint = if (!isFirst) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
}
NuvioButton(onClick = onMoveDown) {
Icon(Icons.Default.KeyboardArrowDown, "Move Down", tint = if (!isLast) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.cd_move_down), tint = if (!isLast) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
}
NuvioButton(onClick = onEdit) {
Icon(Icons.Default.Edit, "Edit", tint = NuvioColors.TextSecondary)
Icon(Icons.Default.Edit, stringResource(R.string.cd_edit), tint = NuvioColors.TextSecondary)
}
NuvioButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = NuvioColors.TextSecondary)
Icon(Icons.Default.Delete, stringResource(R.string.cd_delete), tint = NuvioColors.TextSecondary)
}
}
}
@ -623,17 +625,17 @@ private fun FolderEditorContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Edit Folder",
text = stringResource(R.string.collections_editor_edit_folder),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
NuvioButton(onClick = { viewModel.cancelFolderEdit() }) {
Text("Cancel")
Text(stringResource(R.string.collections_cancel))
}
val canSaveFolder = (uiState.editingFolder?.catalogSources?.isNotEmpty() == true)
NuvioButton(onClick = { viewModel.saveFolderEdit() }, enabled = canSaveFolder) {
Text("Save")
Text(stringResource(R.string.collections_editor_save))
}
}
}
@ -662,13 +664,13 @@ private fun FolderEditorContent(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Folder Title", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(stringResource(R.string.collections_editor_folder_title), style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
NuvioTextField(
value = folder.title,
onValueChange = { viewModel.updateFolderTitle(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = "Folder name",
placeholder = stringResource(R.string.collections_editor_placeholder_folder),
focusRequester = titleFocusRequester
)
}
@ -681,7 +683,7 @@ private fun FolderEditorContent(
else -> "none"
}
Text("Cover", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(stringResource(R.string.collections_editor_cover), style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
@ -703,7 +705,7 @@ private fun FolderEditorContent(
)
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) { Text("None") }
) { Text(stringResource(R.string.collections_editor_cover_none)) }
Button(
onClick = { viewModel.showEmojiPicker() },
@ -726,9 +728,9 @@ private fun FolderEditorContent(
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
if (hasEmoji) {
Text("${folder.coverEmoji} Emoji")
Text("${folder.coverEmoji} ${stringResource(R.string.collections_editor_cover_emoji)}")
} else {
Text("Emoji")
Text(stringResource(R.string.collections_editor_cover_emoji))
}
}
@ -751,7 +753,7 @@ private fun FolderEditorContent(
)
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) { Text("Image URL") }
) { Text(stringResource(R.string.collections_editor_cover_image_url)) }
}
if (coverMode == "image") {
@ -778,7 +780,7 @@ private fun FolderEditorContent(
) {
AsyncImage(
model = folder.coverImageUrl,
contentDescription = "Preview",
contentDescription = stringResource(R.string.cd_preview),
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(12.dp)),
@ -790,13 +792,13 @@ private fun FolderEditorContent(
}
Spacer(modifier = Modifier.height(12.dp))
Text("Focus GIF", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(stringResource(R.string.collections_editor_focus_gif), style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
NuvioTextField(
value = folder.focusGifUrl.orEmpty(),
onValueChange = { viewModel.updateFolderFocusGifUrl(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = "Animated GIF URL (plays only while focused)"
placeholder = stringResource(R.string.collections_editor_placeholder_gif)
)
Spacer(modifier = Modifier.height(12.dp))
@ -823,7 +825,7 @@ private fun FolderEditorContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Play GIF on Focus", style = MaterialTheme.typography.bodyLarge, color = NuvioColors.TextPrimary)
Text(stringResource(R.string.collections_editor_play_gif), style = MaterialTheme.typography.bodyLarge, color = NuvioColors.TextPrimary)
Switch(
checked = folder.focusGifEnabled,
onCheckedChange = { viewModel.updateFolderFocusGifEnabled(it) }
@ -833,7 +835,7 @@ private fun FolderEditorContent(
}
item {
Text("Tile Shape", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(stringResource(R.string.collections_editor_tile_shape), style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
val shapeFocusRequesters = remember { PosterShape.entries.associateWith { FocusRequester() } }
Row(
@ -844,9 +846,9 @@ private fun FolderEditorContent(
) {
PosterShape.entries.forEach { shape ->
val label = when (shape) {
PosterShape.POSTER -> "Poster"
PosterShape.LANDSCAPE -> "Wide"
PosterShape.SQUARE -> "Square"
PosterShape.POSTER -> stringResource(R.string.collections_editor_shape_poster)
PosterShape.LANDSCAPE -> stringResource(R.string.collections_editor_shape_wide)
PosterShape.SQUARE -> stringResource(R.string.collections_editor_shape_square)
}
val isSelected = folder.tileShape == shape
Button(
@ -902,13 +904,13 @@ private fun FolderEditorContent(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Hide Title",
text = stringResource(R.string.collections_editor_hide_title),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Only show the cover image",
text = stringResource(R.string.collections_editor_hide_title_desc),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)
@ -934,9 +936,9 @@ private fun FolderEditorContent(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Catalogs", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(stringResource(R.string.collections_editor_catalogs), style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
Text(
"${folder.catalogSources.size} catalog${if (folder.catalogSources.size != 1) "s" else ""}",
"${folder.catalogSources.size} ${stringResource(R.string.collections_editor_catalogs).lowercase()}",
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
@ -976,7 +978,7 @@ private fun FolderEditorContent(
color = if (isMissing) NuvioColors.Error else NuvioColors.TextPrimary
)
Text(
text = if (isMissing) "Addon not installed: ${source.addonId}" else "${source.type} - ${catalog.addonName}",
text = if (isMissing) stringResource(R.string.collections_editor_addon_missing, source.addonId) else "${source.type} - ${catalog.addonName}",
style = MaterialTheme.typography.bodySmall,
color = if (isMissing) NuvioColors.Error.copy(alpha = 0.7f) else NuvioColors.TextTertiary
)
@ -998,7 +1000,7 @@ private fun FolderEditorContent(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.KeyboardArrowUp, "Move Up", tint = if (index > 0) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
Icon(Icons.Default.KeyboardArrowUp, stringResource(R.string.cd_move_up), tint = if (index > 0) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
}
Button(
onClick = { viewModel.moveCatalogSourceDown(index) },
@ -1016,7 +1018,7 @@ private fun FolderEditorContent(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.KeyboardArrowDown, "Move Down", tint = if (index < folder.catalogSources.size - 1) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.cd_move_down), tint = if (index < folder.catalogSources.size - 1) NuvioColors.TextSecondary else NuvioColors.TextTertiary)
}
Button(
onClick = {
@ -1038,7 +1040,7 @@ private fun FolderEditorContent(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.Close, "Remove")
Icon(Icons.Default.Close, stringResource(R.string.cd_remove))
}
}
}
@ -1047,9 +1049,9 @@ private fun FolderEditorContent(
item {
NuvioButton(onClick = { viewModel.showCatalogPicker() }) {
Icon(Icons.Default.Add, "Add")
Icon(Icons.Default.Add, stringResource(R.string.cd_add))
Spacer(modifier = Modifier.width(8.dp))
Text("Add Catalog")
Text(stringResource(R.string.collections_editor_add_catalog))
}
}
}
@ -1075,11 +1077,11 @@ private fun CatalogPickerContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Select Catalogs",
text = stringResource(R.string.collections_editor_select_catalogs),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
NuvioButton(onClick = onBack) { Text("Done") }
NuvioButton(onClick = onBack) { Text(stringResource(R.string.collections_editor_done)) }
}
Spacer(modifier = Modifier.height(16.dp))
@ -1144,7 +1146,7 @@ private fun CatalogPickerContent(
} else {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add",
contentDescription = stringResource(R.string.cd_add),
tint = NuvioColors.TextTertiary
)
}
@ -1214,11 +1216,11 @@ private fun EmojiPickerContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Choose Emoji",
text = stringResource(R.string.collections_editor_choose_emoji),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
NuvioButton(onClick = onBack) { Text("Back") }
NuvioButton(onClick = onBack) { Text(stringResource(R.string.collections_editor_back)) }
}
Spacer(modifier = Modifier.height(16.dp))

View file

@ -63,6 +63,8 @@ import com.nuvio.tv.data.local.ValidationResult
import com.nuvio.tv.domain.model.Collection
import com.nuvio.tv.ui.components.LoadingIndicator
import com.nuvio.tv.ui.theme.NuvioColors
import com.nuvio.tv.R
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -159,7 +161,7 @@ fun CollectionManagementScreen(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Collections",
text = stringResource(R.string.collections_header),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
@ -191,23 +193,29 @@ fun CollectionManagementScreen(
val uri = resolver.insert(existingUri, values)
uri?.let { resolver.openOutputStream(it)?.use { os -> os.write(json.toByteArray()) } }
}
exportMessage = "Saved to Downloads"
exportMessage = "saved"
} catch (_: Exception) {
exportMessage = "Export failed"
exportMessage = "failed"
}
}
}) {
Text(exportMessage ?: "Export File")
Text(exportMessage?.let {
when (it) {
"saved" -> stringResource(R.string.collections_saved_downloads)
"failed" -> stringResource(R.string.collections_export_failed)
else -> it
}
} ?: stringResource(R.string.collections_export_file))
}
}
NuvioButton(onClick = { viewModel.showImportDialog() }) {
Text("Import")
Text(stringResource(R.string.collections_import))
}
NuvioButton(
onClick = { onNavigateToEditor(null) },
modifier = Modifier.focusRequester(newButtonFocusRequester)
) {
Text("New Collection")
Text(stringResource(R.string.collections_new))
}
}
}
@ -220,7 +228,7 @@ fun CollectionManagementScreen(
contentAlignment = Alignment.Center
) {
Text(
text = "No collections yet. Create one to organize your catalogs.",
text = stringResource(R.string.collections_empty),
color = NuvioColors.TextSecondary,
style = MaterialTheme.typography.bodyLarge
)
@ -282,12 +290,12 @@ private fun ImportContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Import Collections",
text = stringResource(R.string.collections_import_header),
style = MaterialTheme.typography.headlineMedium,
color = NuvioColors.TextPrimary
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
NuvioButton(onClick = onBack) { Text("Cancel") }
NuvioButton(onClick = onBack) { Text(stringResource(R.string.collections_cancel)) }
}
}
@ -300,7 +308,7 @@ private fun ImportContent(
) {
item {
Text(
text = "Import collections from JSON. Catalogs from addons you don't have installed will show a warning.",
text = stringResource(R.string.collections_import_description),
style = MaterialTheme.typography.bodyMedium,
color = NuvioColors.TextSecondary
)
@ -311,9 +319,9 @@ private fun ImportContent(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ImportMode.entries.filter { it != ImportMode.PASTE }.forEach { mode ->
val label = when (mode) {
ImportMode.PASTE -> "Paste JSON"
ImportMode.FILE -> "From File"
ImportMode.URL -> "From URL"
ImportMode.PASTE -> stringResource(R.string.collections_mode_paste)
ImportMode.FILE -> stringResource(R.string.collections_mode_file)
ImportMode.URL -> stringResource(R.string.collections_mode_url)
}
if (mode == importMode) {
Button(
@ -349,9 +357,9 @@ private fun ImportContent(
when (importMode) {
ImportMode.PASTE -> {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
NuvioButton(onClick = onPaste) { Text("Paste from Clipboard") }
NuvioButton(onClick = onPaste) { Text(stringResource(R.string.collections_paste_clipboard)) }
if (importText.isNotBlank()) {
NuvioButton(onClick = onValidate) { Text("Validate") }
NuvioButton(onClick = onValidate) { Text(stringResource(R.string.collections_validate)) }
}
}
@ -381,7 +389,7 @@ private fun ImportContent(
decorationBox = { innerTextField ->
if (importText.isEmpty()) {
Text(
text = "Paste collections JSON here...",
text = stringResource(R.string.collections_paste_hint),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
@ -395,17 +403,17 @@ private fun ImportContent(
ImportMode.FILE -> {
Text(
text = "Reads nuvio-collections.json from the Downloads folder.",
text = stringResource(R.string.collections_file_description),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
Spacer(modifier = Modifier.height(8.dp))
NuvioButton(onClick = onPickFile) { Text("Load File") }
NuvioButton(onClick = onPickFile) { Text(stringResource(R.string.collections_load_file)) }
if (importText.isNotBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "File loaded (${importText.length} characters)",
text = stringResource(R.string.collections_file_loaded, importText.length),
style = MaterialTheme.typography.bodyMedium,
color = NuvioColors.TextSecondary
)
@ -452,7 +460,7 @@ private fun ImportContent(
Spacer(modifier = Modifier.height(12.dp))
NuvioButton(onClick = onFetchUrl) {
Text(if (isLoadingImport) "Fetching..." else "Fetch URL")
Text(if (isLoadingImport) stringResource(R.string.collections_fetching) else stringResource(R.string.collections_fetch_url))
}
if (isLoadingImport) {
@ -478,20 +486,19 @@ private fun ImportContent(
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Valid JSON",
text = stringResource(R.string.collections_valid_json),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.Primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${validationResult.collectionCount} collection${if (validationResult.collectionCount != 1) "s" else ""}, " +
"${validationResult.folderCount} folder${if (validationResult.folderCount != 1) "s" else ""}",
text = stringResource(R.string.collections_valid_summary, validationResult.collectionCount, validationResult.folderCount),
style = MaterialTheme.typography.bodyMedium,
color = NuvioColors.TextSecondary
)
Spacer(modifier = Modifier.height(12.dp))
NuvioButton(onClick = onConfirmImport) {
Text("Confirm Import")
Text(stringResource(R.string.collections_confirm_import))
}
}
}
@ -542,7 +549,7 @@ private fun CollectionListItem(
color = NuvioColors.TextPrimary
)
Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}",
text = stringResource(R.string.collections_folder_count, collection.folders.size),
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
@ -566,7 +573,7 @@ private fun CollectionListItem(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.KeyboardArrowUp, "Move Up")
Icon(Icons.Default.KeyboardArrowUp, stringResource(R.string.cd_move_up))
}
Button(
@ -586,7 +593,7 @@ private fun CollectionListItem(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.KeyboardArrowDown, "Move Down")
Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.cd_move_down))
}
Button(
@ -605,7 +612,7 @@ private fun CollectionListItem(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.Edit, "Edit")
Icon(Icons.Default.Edit, stringResource(R.string.cd_edit))
}
Button(
@ -624,7 +631,7 @@ private fun CollectionListItem(
),
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.Delete, "Delete")
Icon(Icons.Default.Delete, stringResource(R.string.cd_delete))
}
}
}

View file

@ -53,6 +53,8 @@ import com.nuvio.tv.domain.model.HomeLayout
import com.nuvio.tv.ui.components.CatalogRowSection
import com.nuvio.tv.ui.components.ContentCard
import com.nuvio.tv.ui.components.LoadingIndicator
import com.nuvio.tv.R
import androidx.compose.ui.res.stringResource
import com.nuvio.tv.ui.components.PosterCardDefaults
import com.nuvio.tv.ui.components.PosterCardStyle
import com.nuvio.tv.ui.screens.home.ClassicHomeContent
@ -94,13 +96,17 @@ fun FolderDetailScreen(
{ item -> uiState.movieWatchedStatus[com.nuvio.tv.ui.screens.home.homeItemStatusKey(item.id, item.apiType)] == true }
}
val enrichingItemId by viewModel.enrichingItemId.collectAsStateWithLifecycle()
if (uiState.viewMode == FolderViewMode.FOLLOW_LAYOUT) {
FollowLayoutContent(
uiState = uiState,
focusState = followLayoutFocusState,
enrichingItemId = enrichingItemId,
onNavigateToDetail = onNavigateToDetail,
onSaveFocusState = viewModel::saveFollowLayoutFocusState,
onSaveGridFocusState = viewModel::saveFollowLayoutGridFocusState
onSaveGridFocusState = viewModel::saveFollowLayoutGridFocusState,
onItemFocus = viewModel::onItemFocused
)
} else {
Column(
@ -257,12 +263,18 @@ private fun TabbedGridContent(
horizontalAlignment = Alignment.Start
) {
Text(
text = tab.label,
text = if (tab.isAllTab) stringResource(R.string.collections_tab_all) else tab.label,
style = MaterialTheme.typography.labelLarge
)
if (tab.typeLabel.isNotBlank()) {
val localizedType = when {
tab.isAllTab -> stringResource(R.string.collections_tab_combined)
tab.rawType.lowercase() == "movie" -> stringResource(R.string.type_movie)
tab.rawType.lowercase() == "series" -> stringResource(R.string.type_series)
else -> tab.typeLabel
}
Text(
text = tab.typeLabel,
text = localizedType,
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextTertiary
)
@ -425,6 +437,9 @@ private fun RowsContent(
}
}
val strTypeMovie = stringResource(R.string.type_movie)
val strTypeSeries = stringResource(R.string.type_series)
LazyColumn(
state = columnListState,
modifier = Modifier.fillMaxSize(),
@ -433,11 +448,25 @@ private fun RowsContent(
) {
sourceTabs.forEachIndexed { index, tab ->
item(key = "row_${index}_${tab.label}") {
val localizedTypeLabel = remember(tab.rawType, strTypeMovie, strTypeSeries) {
when (tab.rawType.lowercase()) {
"movie" -> strTypeMovie
"series" -> strTypeSeries
else -> tab.rawType.replaceFirstChar { it.uppercase() }
}
}
val rowTitle = remember(tab.label, localizedTypeLabel) {
if (tab.label != tab.typeLabel && localizedTypeLabel.isNotEmpty()) {
"${tab.label} - $localizedTypeLabel"
} else {
tab.label
}
}
when {
tab.isLoading -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = tab.label,
text = rowTitle,
style = MaterialTheme.typography.headlineSmall,
color = NuvioColors.TextPrimary,
modifier = Modifier.padding(start = 48.dp, end = 48.dp, bottom = 12.dp)
@ -455,7 +484,7 @@ private fun RowsContent(
tab.error != null -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = tab.label,
text = rowTitle,
style = MaterialTheme.typography.headlineSmall,
color = NuvioColors.TextPrimary,
modifier = Modifier.padding(start = 48.dp, end = 48.dp, bottom = 12.dp)
@ -483,7 +512,7 @@ private fun RowsContent(
onItemClick = onNavigateToDetail,
showPosterLabels = true,
showAddonName = false,
showCatalogTypeSuffix = false,
showCatalogTypeSuffix = true,
isItemWatched = isItemWatched,
listState = listState,
focusedItemIndex = if (
@ -510,9 +539,11 @@ private fun RowsContent(
private fun FollowLayoutContent(
uiState: FolderDetailUiState,
focusState: HomeScreenFocusState,
enrichingItemId: String? = null,
onNavigateToDetail: (String, String, String) -> Unit,
onSaveFocusState: (Int, Int, Int, Int, Map<String, Int>) -> Unit,
onSaveGridFocusState: (Int, Int, String?) -> Unit
onSaveGridFocusState: (Int, Int, String?) -> Unit,
onItemFocus: (MetaPreview) -> Unit = {}
) {
val homeState = uiState.followLayoutHomeState
@ -569,6 +600,7 @@ private fun FollowLayoutContent(
HomeLayout.MODERN -> ModernHomeContent(
uiState = homeState,
focusState = focusState,
enrichingItemId = enrichingItemId,
trailerPreviewUrls = emptyMap(),
trailerPreviewAudioUrls = emptyMap(),
onNavigateToDetail = onNavigateToDetail,
@ -578,6 +610,7 @@ private fun FollowLayoutContent(
onRemoveContinueWatching = noOpRemoveCw,
isCatalogItemWatched = isItemWatched,
onNavigateToFolderDetail = noOpFolderDetail,
onItemFocus = onItemFocus,
onSaveFocusState = onSaveFocusState
)
}

View file

@ -47,6 +47,7 @@ data class FolderDetailUiState(
data class FolderTab(
val label: String,
val typeLabel: String = "",
val rawType: String = "",
val catalogRow: CatalogRow? = null,
val isLoading: Boolean = true,
val error: String? = null,
@ -68,7 +69,13 @@ class FolderDetailViewModel @Inject constructor(
private val catalogRepository: CatalogRepository,
private val layoutPreferenceDataStore: LayoutPreferenceDataStore,
private val watchProgressRepository: WatchProgressRepository,
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder,
private val tmdbService: com.nuvio.tv.core.tmdb.TmdbService,
private val tmdbMetadataService: com.nuvio.tv.core.tmdb.TmdbMetadataService,
private val tmdbSettingsDataStore: com.nuvio.tv.data.local.TmdbSettingsDataStore,
private val mdbListRepository: com.nuvio.tv.data.repository.MDBListRepository,
private val mdbListSettingsDataStore: com.nuvio.tv.data.local.MDBListSettingsDataStore,
private val metaRepository: com.nuvio.tv.domain.repository.MetaRepository
) : ViewModel() {
private val collectionId: String = savedStateHandle["collectionId"] ?: ""
@ -79,6 +86,10 @@ class FolderDetailViewModel @Inject constructor(
private var movieWatchedJob: Job? = null
private var seriesWatchedJob: Job? = null
private var enrichFocusJob: Job? = null
private val enrichedItemIds = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val _enrichingItemId = MutableStateFlow<String?>(null)
val enrichingItemId: StateFlow<String?> = _enrichingItemId.asStateFlow()
private val _rowsFocusState = MutableStateFlow(com.nuvio.tv.ui.screens.home.HomeScreenFocusState())
val rowsFocusState: StateFlow<com.nuvio.tv.ui.screens.home.HomeScreenFocusState> = _rowsFocusState.asStateFlow()
@ -127,8 +138,10 @@ class FolderDetailViewModel @Inject constructor(
val sourceTabs = folder.catalogSources.map { source ->
val addon = addons.find { it.id == source.addonId }
val catalog = addon?.catalogs?.find { it.id == source.catalogId && it.apiType == source.type }
?: addon?.catalogs?.find { it.id == source.catalogId.substringBefore(",") && it.apiType == source.type }
?: addons.firstNotNullOfOrNull { a -> a.catalogs.find { it.id == source.catalogId && it.apiType == source.type } }
val (name, typeLabel) = buildTabLabels(source, catalog?.name)
FolderTab(label = name, typeLabel = typeLabel, isLoading = true)
FolderTab(label = name, typeLabel = typeLabel, rawType = source.type, isLoading = true)
}
val tabs = if (showAll) {
@ -266,13 +279,27 @@ class FolderDetailViewModel @Inject constructor(
return@launch
}
val catalog = addon.catalogs.find { it.id == source.catalogId && it.apiType == source.type }
val catalogName = catalog?.name ?: source.catalogId
var catalog = addon.catalogs.find { it.id == source.catalogId && it.apiType == source.type }
?: addon.catalogs.find { it.id == source.catalogId.substringBefore(",") && it.apiType == source.type }
// If the catalog wasn't found in the declared addon, search all installed addons.
var effectiveAddon: com.nuvio.tv.domain.model.Addon = addon
if (catalog == null) {
for (a in addons) {
val match = a.catalogs.find { it.id == source.catalogId && it.apiType == source.type }
if (match != null) {
effectiveAddon = a
catalog = match
break
}
}
}
val tab = _uiState.value.tabs.getOrNull(tabIndex)
val catalogName = catalog?.name ?: tab?.label?.takeIf { it != tab?.typeLabel } ?: source.catalogId
catalogRepository.getCatalog(
addonBaseUrl = addon.baseUrl,
addonId = addon.id,
addonName = addon.displayName,
addonBaseUrl = effectiveAddon.baseUrl,
addonId = effectiveAddon.id,
addonName = effectiveAddon.displayName,
catalogId = source.catalogId,
catalogName = catalogName,
type = source.type,
@ -447,4 +474,139 @@ class FolderDetailViewModel @Inject constructor(
}
return name to typeLabel
}
fun onItemFocused(item: MetaPreview) {
// Clear enriching for previous item immediately.
if (_enrichingItemId.value != null && _enrichingItemId.value != item.id) {
_enrichingItemId.value = null
}
if (item.id in enrichedItemIds) return
// Check if any enrichment source is active — if so, signal enriching immediately
// so hero content hides until enrichment completes.
val viewMode = _uiState.value.viewMode
if (viewMode == FolderViewMode.FOLLOW_LAYOUT && _uiState.value.homeLayout == HomeLayout.MODERN) {
_enrichingItemId.value = item.id
}
enrichFocusJob?.cancel()
enrichFocusJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
kotlinx.coroutines.delay(350)
val tmdbSettings = tmdbSettingsDataStore.settings.first()
val mdbSettings = mdbListSettingsDataStore.settings.first()
val homeLayout = _uiState.value.homeLayout
val tmdbEnabled = tmdbSettings.enabled &&
(homeLayout != HomeLayout.MODERN || tmdbSettings.modernHomeEnabled)
val mdbEnabled = mdbSettings.enabled && mdbSettings.apiKey.isNotBlank()
val externalMetaEnabled = layoutPreferenceDataStore.preferExternalMetaAddonDetail.first()
if (!tmdbEnabled && !mdbEnabled && !externalMetaEnabled) {
if (_enrichingItemId.value == item.id) _enrichingItemId.value = null
return@launch
}
val mdbRating = if (mdbEnabled) {
runCatching { mdbListRepository.getImdbRatingForItem(item.id, item.apiType) }.getOrNull()
} else null
var enrichment: com.nuvio.tv.core.tmdb.TmdbEnrichment? = null
if (tmdbEnabled) {
val tmdbId = runCatching { tmdbService.ensureTmdbId(item.id, item.apiType) }.getOrNull()
if (tmdbId != null) {
enrichment = runCatching {
tmdbMetadataService.fetchEnrichment(
tmdbId = tmdbId,
contentType = item.type,
language = tmdbSettings.language
)
}.getOrNull()
}
}
if (enrichment == null && mdbRating == null && !externalMetaEnabled) return@launch
enrichedItemIds.add(item.id)
// Apply TMDB + MDB enrichment if available.
if (enrichment != null || mdbRating != null) {
val finalEnrichment = enrichment
val finalMdbRating = mdbRating
updateItemInTabs(item.id) { merged ->
var result = merged
if (finalEnrichment != null) {
if (tmdbSettings.useBasicInfo) {
result = result.copy(
name = finalEnrichment.localizedTitle ?: result.name,
description = finalEnrichment.description ?: result.description,
genres = if (finalEnrichment.genres.isNotEmpty()) finalEnrichment.genres else result.genres,
imdbRating = finalEnrichment.rating?.toFloat() ?: result.imdbRating
)
}
if (tmdbSettings.useArtwork) {
result = result.copy(
background = finalEnrichment.backdrop ?: result.background,
logo = finalEnrichment.logo ?: result.logo
)
}
if (tmdbSettings.useReleaseDates) {
result = result.copy(
releaseInfo = finalEnrichment.releaseInfo ?: result.releaseInfo
)
}
if (tmdbSettings.useDetails) {
result = result.copy(
ageRating = finalEnrichment.ageRating ?: result.ageRating,
status = finalEnrichment.status ?: result.status
)
}
}
if (finalMdbRating != null && result.imdbRating == null) {
result = result.copy(imdbRating = finalMdbRating.toFloat())
}
result
}
}
// External meta addon fallback when TMDB didn't enrich.
if (enrichment == null && externalMetaEnabled) {
val metaResult = metaRepository.getMetaFromAllAddons(item.apiType, item.id)
.first { it is NetworkResult.Success || it is NetworkResult.Error }
if (metaResult is NetworkResult.Success) {
val meta = metaResult.data
updateItemInTabs(item.id) { merged ->
merged.copy(
name = meta.name.takeIf { it.isNotBlank() } ?: merged.name,
description = meta.description?.takeIf { it.isNotBlank() } ?: merged.description,
background = meta.background?.takeIf { it.isNotBlank() } ?: merged.background,
logo = meta.logo?.takeIf { it.isNotBlank() } ?: merged.logo,
genres = meta.genres.takeIf { it.isNotEmpty() } ?: merged.genres,
imdbRating = meta.imdbRating ?: merged.imdbRating,
releaseInfo = meta.releaseInfo?.takeIf { it.isNotBlank() } ?: merged.releaseInfo
)
}
}
}
// Sync enriched tabs into followLayoutHomeState for FOLLOW_LAYOUT mode.
if (_enrichingItemId.value == item.id) _enrichingItemId.value = null
rebuildFollowLayoutState()
}
}
private fun updateItemInTabs(itemId: String, transform: (MetaPreview) -> MetaPreview) {
_uiState.update { state ->
var changed = false
val updatedTabs = state.tabs.map { tab ->
val row = tab.catalogRow ?: return@map tab
val idx = row.items.indexOfFirst { it.id == itemId }
if (idx < 0) return@map tab
val merged = transform(row.items[idx])
if (merged == row.items[idx]) return@map tab
changed = true
val items = row.items.toMutableList()
items[idx] = merged
tab.copy(catalogRow = row.copy(items = items))
}
if (changed) state.copy(tabs = updatedTabs) else state
}
}
}

View file

@ -1144,7 +1144,7 @@ class MetaDetailsViewModel @Inject constructor(
video.copy(
title = ep?.title ?: video.title,
overview = ep?.overview ?: video.overview,
released = ep?.airDate ?: video.released,
released = if (settings.useReleaseDates) ep?.airDate ?: video.released else video.released,
thumbnail = ep?.thumbnail ?: video.thumbnail,
runtime = ep?.runtimeMinutes
)

View file

@ -82,7 +82,13 @@ internal fun HomeViewModel.observeTmdbSettingsPipeline() {
tmdbSettingsDataStore.settings
.distinctUntilChanged()
.collectLatest { settings ->
val languageChanged = currentTmdbSettings.language != settings.language
currentTmdbSettings = settings
if (languageChanged) {
// Allow re-enrichment with the new language on next focus.
prefetchedTmdbIds.clear()
prefetchedExternalMetaIds.clear()
}
scheduleUpdateCatalogRows()
}
}

View file

@ -1098,7 +1098,7 @@ internal fun mergeContinueWatchingItems(
filteredNextUpItems.forEach { combined.add(it.info.sortTimestamp to it) }
val seen = mutableSetOf<String>()
return combined
val result = combined
.sortedByDescending { it.first }
.map { it.second }
.filter { item ->
@ -1108,6 +1108,8 @@ internal fun mergeContinueWatchingItems(
}
contentId.isBlank() || seen.add(contentId)
}
return result
}
private suspend fun HomeViewModel.buildNextUpItem(
@ -1274,7 +1276,7 @@ private suspend fun HomeViewModel.enrichNextUpItem(
} else {
null
}
val released = tmdbData?.airDate
val released = (if (currentTmdbSettings.useReleaseDates) tmdbData?.airDate else null)
?: video?.released?.trim()?.takeIf { it.isNotEmpty() }
?: item.info.released
val releaseDate = parseEpisodeReleaseDate(released)

View file

@ -174,7 +174,16 @@ fun ModernHomeContent(
trailerPlaybackTarget == FocusedPosterTrailerPlaybackTarget.HERO_MEDIA)
val visibleHomeRows = remember(uiState.homeRows, uiState.catalogRows) {
if (uiState.homeRows.isNotEmpty()) {
uiState.homeRows
val latestCatalogByKey = uiState.catalogRows.associateBy { catalogRowKey(it) }
uiState.homeRows.map { homeRow ->
when (homeRow) {
is HomeRow.Catalog -> {
val latest = latestCatalogByKey[catalogRowKey(homeRow.row)]
if (latest != null && latest !== homeRow.row) HomeRow.Catalog(latest) else homeRow
}
else -> homeRow
}
}
} else {
uiState.catalogRows.filter { it.items.isNotEmpty() }.map { HomeRow.Catalog(it) }
}

View file

@ -52,6 +52,9 @@ class PlayerRuntimeController(
internal val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore,
internal val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences,
internal val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore,
internal val tmdbService: com.nuvio.tv.core.tmdb.TmdbService,
internal val tmdbMetadataService: com.nuvio.tv.core.tmdb.TmdbMetadataService,
internal val tmdbSettingsDataStore: com.nuvio.tv.data.local.TmdbSettingsDataStore,
savedStateHandle: SavedStateHandle,
internal val scope: CoroutineScope
) {

View file

@ -2,6 +2,7 @@ package com.nuvio.tv.ui.screens.player
import com.nuvio.tv.R
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.Meta
import com.nuvio.tv.domain.model.Stream
import kotlinx.coroutines.delay
@ -28,6 +29,10 @@ internal fun PlayerRuntimeController.fetchMetaDetails(id: String?, type: String?
}
}
}
scope.launch {
enrichDescriptionFromTmdb(id, type)
}
}
internal fun PlayerRuntimeController.applyMetaDetails(meta: Meta) {
@ -63,6 +68,82 @@ internal fun PlayerRuntimeController.updateEpisodeDescription() {
if (!overview.isNullOrBlank()) {
_uiState.update { it.copy(description = overview) }
}
// Re-enrich from TMDB for the new episode.
scope.launch {
enrichDescriptionFromTmdb(contentId, contentType)
}
}
private suspend fun PlayerRuntimeController.enrichDescriptionFromTmdb(id: String?, type: String?) {
if (id.isNullOrBlank() || type.isNullOrBlank()) return
val settings = tmdbSettingsDataStore.settings.first()
if (!settings.enabled || !settings.useBasicInfo) return
val tmdbId = runCatching { tmdbService.ensureTmdbId(id, type) }.getOrNull() ?: return
val contentType = when (type.lowercase()) {
"series", "tv" -> ContentType.SERIES
else -> ContentType.MOVIE
}
val enrichment = runCatching {
tmdbMetadataService.fetchEnrichment(
tmdbId = tmdbId,
contentType = contentType,
language = settings.language
)
}.getOrNull() ?: return
val isSeries = type.lowercase() in listOf("series", "tv")
val season = currentSeason
val episode = currentEpisode
// For series, try to get episode-level overview and title from TMDB.
val episodeEnrichment = if (isSeries && season != null && episode != null) {
runCatching {
tmdbMetadataService.fetchEpisodeEnrichment(
tmdbId = tmdbId,
seasonNumbers = listOf(season),
language = settings.language
)[season to episode]
}.getOrNull()
} else null
val tmdbDescription = episodeEnrichment?.overview ?: enrichment.description
if (settings.useBasicInfo && !tmdbDescription.isNullOrBlank()) {
_uiState.update { it.copy(description = tmdbDescription) }
}
// Enrich title from TMDB (localized).
if (settings.useBasicInfo) {
val tmdbTitle = enrichment.localizedTitle
if (!tmdbTitle.isNullOrBlank()) {
_uiState.update { it.copy(title = tmdbTitle) }
}
}
// Enrich logo from TMDB if artwork is enabled.
if (settings.useArtwork) {
val tmdbLogo = enrichment.logo
if (!tmdbLogo.isNullOrBlank()) {
_uiState.update { it.copy(logo = tmdbLogo) }
}
}
// Also enrich episode title from TMDB if available.
if (settings.useBasicInfo) {
val tmdbEpisodeTitle = episodeEnrichment?.title
if (!tmdbEpisodeTitle.isNullOrBlank()) {
_uiState.update { it.copy(currentEpisodeTitle = tmdbEpisodeTitle) }
}
}
// Enrich cast from TMDB if addon didn't provide any.
if (settings.useBasicInfo && enrichment.castMembers.isNotEmpty()) {
_uiState.update { state ->
if (state.castMembers.isEmpty()) state.copy(castMembers = enrichment.castMembers)
else state
}
}
}
internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boolean) {

View file

@ -16,6 +16,9 @@ import com.nuvio.tv.domain.repository.AddonRepository
import com.nuvio.tv.domain.repository.MetaRepository
import com.nuvio.tv.domain.repository.StreamRepository
import com.nuvio.tv.domain.repository.WatchProgressRepository
import com.nuvio.tv.core.tmdb.TmdbService
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.data.local.TmdbSettingsDataStore
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.StateFlow
@ -39,6 +42,9 @@ class PlayerViewModel @Inject constructor(
private val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore,
private val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences,
private val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore,
private val tmdbService: TmdbService,
private val tmdbMetadataService: TmdbMetadataService,
private val tmdbSettingsDataStore: TmdbSettingsDataStore,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -59,6 +65,9 @@ class PlayerViewModel @Inject constructor(
layoutPreferenceDataStore = layoutPreferenceDataStore,
watchedItemsPreferences = watchedItemsPreferences,
trackPreferenceDataStore = trackPreferenceDataStore,
tmdbService = tmdbService,
tmdbMetadataService = tmdbMetadataService,
tmdbSettingsDataStore = tmdbSettingsDataStore,
savedStateHandle = savedStateHandle,
scope = viewModelScope
)

View file

@ -1246,10 +1246,13 @@
<string name="cd_loading_logo">Ładowanie logo</string>
<string name="cd_move_up">Przesuń w górę</string>
<string name="cd_move_down">Przesuń w dół</string>
<string name="cd_edit">Edytuj</string>
<string name="cd_delete">Usuń</string>
<string name="cd_qr_code">Kod QR</string>
<string name="cd_add">Dodaj</string>
<string name="cd_refresh">Odśwież</string>
<string name="cd_remove">Usuń</string>
<string name="cd_preview">Podgląd</string>
<string name="cd_test">Test</string>
<string name="cd_unlink">Odłącz</string>
<string name="cd_nuvio_logo">NuvioTV</string>
@ -1355,6 +1358,22 @@
<string name="web_manage_collections_title">Zarządzaj kolekcjami</string>
<string name="web_manage_collections_subtitle">Twórz i zarządzaj swoimi kolekcjami</string>
<string name="web_status_msg_collections_updated">Twoje kolekcje zostały zaktualizowane na TV.</string>
<string name="web_tab_addons">Dodatki</string>
<string name="web_tab_home_layout">Układ ekranu głównego</string>
<string name="web_tab_collections">Kolekcje</string>
<string name="web_btn_enable_all">Włącz wszystko</string>
<string name="web_btn_disable_all">Wyłącz wszystko</string>
<string name="web_btn_show_all">Pokaż wszystko</string>
<string name="web_btn_hide_all">Ukryj wszystko</string>
<string name="web_btn_new_collection">+ Nowa kolekcja</string>
<string name="web_btn_export">Eksportuj</string>
<string name="web_btn_import">Importuj</string>
<string name="web_no_collections">Brak kolekcji</string>
<string name="web_badge_collection">Kolekcja</string>
<string name="web_import_collections_title">Importuj kolekcje</string>
<string name="web_import_tab_paste">Wklej</string>
<string name="web_import_tab_file">Plik</string>
<string name="web_import_tab_url">URL</string>
<string name="web_error_failed_save">Nie udało się zapisać. Sprawdź połączenie z TV.</string>
<string name="web_no_repos">Brak zainstalowanych repozytoriów</string>
<string name="web_manage_repos_title">Zarządzaj repozytoriami</string>
@ -1384,4 +1403,80 @@
<string name="player_engine_switching_manual_message">Przełączanie na %1$s…</string>
<string name="search_recent_title">Ostatnie wyszukiwania</string>
<string name="search_recent_clear">Wyczyść historię</string>
<!-- Collection Management -->
<string name="collections_header">Kolekcje</string>
<string name="collections_empty">Brak kolekcji. Utwórz jedną, aby uporządkować katalogi.</string>
<string name="collections_new">Nowa kolekcja</string>
<string name="collections_import">Importuj</string>
<string name="collections_export_file">Eksportuj plik</string>
<string name="collections_saved_downloads">Zapisano w Pobrane</string>
<string name="collections_export_failed">Eksport nie powiódł się</string>
<string name="collections_import_header">Importuj kolekcje</string>
<string name="collections_import_description">Importuj kolekcje z JSON. Katalogi z niezainstalowanych dodatków pokażą ostrzeżenie.</string>
<string name="collections_cancel">Anuluj</string>
<string name="collections_mode_paste">Wklej JSON</string>
<string name="collections_mode_file">Z pliku</string>
<string name="collections_mode_url">Z adresu URL</string>
<string name="collections_paste_clipboard">Wklej ze schowka</string>
<string name="collections_validate">Sprawdź</string>
<string name="collections_paste_hint">Wklej JSON kolekcji tutaj…</string>
<string name="collections_file_description">Odczytuje nuvio-collections.json z folderu Pobrane.</string>
<string name="collections_load_file">Wczytaj plik</string>
<string name="collections_file_loaded">Plik wczytany (%1$d znaków)</string>
<string name="collections_fetch_url">Pobierz URL</string>
<string name="collections_fetching">Pobieranie…</string>
<string name="collections_valid_json">Poprawny JSON</string>
<string name="collections_valid_summary">%1$d kolekcji, %2$d folderów</string>
<string name="collections_confirm_import">Potwierdź import</string>
<string name="collections_folder_count">%1$d folderów</string>
<!-- Collection Editor -->
<string name="collections_editor_row_title">Tytuł wiersza</string>
<string name="collections_editor_save">Zapisz</string>
<string name="collections_editor_backdrop">Obraz tła</string>
<string name="collections_editor_pin_above">Przypnij nad katalogami</string>
<string name="collections_editor_pin_above_desc">Pokaż tę kolekcję nad wszystkimi zwykłymi katalogami. Wiele przypiętych kolekcji zachowuje kolejność tworzenia.</string>
<string name="collections_editor_focus_glow">Poświata zaznaczonej karty</string>
<string name="collections_editor_focus_glow_desc">Użyj natywnej poświaty TV na kartach tej kolekcji na ekranie głównym</string>
<string name="collections_editor_view_mode">Tryb widoku</string>
<string name="collections_editor_show_all_tab">Pokaż kartę \"Wszystko\"</string>
<string name="collections_editor_show_all_tab_desc">Połącz wszystkie katalogi w jednej karcie</string>
<string name="collections_editor_folders">Foldery</string>
<string name="collections_editor_add_folder">Dodaj folder</string>
<string name="collections_editor_edit_folder">Edytuj folder</string>
<string name="collections_editor_folder_title">Tytuł folderu</string>
<string name="collections_editor_cover">Okładka</string>
<string name="collections_editor_cover_none">Brak</string>
<string name="collections_editor_cover_emoji">Emoji</string>
<string name="collections_editor_cover_image_url">URL obrazu</string>
<string name="collections_editor_focus_gif">GIF fokusa</string>
<string name="collections_editor_play_gif">Odtwarzaj GIF przy fokusie</string>
<string name="collections_editor_tile_shape">Kształt kafelka</string>
<string name="collections_editor_hide_title">Ukryj tytuł</string>
<string name="collections_editor_hide_title_desc">Pokaż tylko obraz okładki</string>
<string name="collections_editor_display">Wyświetlanie</string>
<string name="collections_editor_catalogs">Katalogi</string>
<string name="collections_editor_add_catalog">Dodaj katalog</string>
<string name="collections_editor_select_catalogs">Wybierz katalogi</string>
<string name="collections_editor_done">Gotowe</string>
<string name="collections_editor_choose_emoji">Wybierz emoji</string>
<string name="collections_editor_back">Wstecz</string>
<string name="collections_editor_edit_collection">Edytuj kolekcję</string>
<string name="collections_editor_catalog_count">%1$d katalogów</string>
<string name="collections_editor_view_mode_tabs">Karty</string>
<string name="collections_editor_view_mode_rows">Wiersze</string>
<string name="collections_editor_view_mode_follow">Układ ekranu głównego</string>
<string name="collections_editor_shape_poster">Plakat</string>
<string name="collections_editor_shape_wide">Szeroki</string>
<string name="collections_editor_shape_square">Kwadrat</string>
<string name="collections_editor_addon_missing">Dodatek niezainstalowany: %1$s</string>
<string name="collections_editor_placeholder_name">Nazwa kolekcji</string>
<string name="collections_editor_placeholder_backdrop">URL obrazu tła (opcjonalnie)</string>
<string name="collections_editor_placeholder_folder">Nazwa folderu</string>
<string name="collections_editor_placeholder_gif">URL animowanego GIF (odtwarzany przy zaznaczeniu)</string>
<string name="collections_card_title">Kolekcje</string>
<string name="collections_card_subtitle">Grupuj katalogi w folderach na ekranie głównym</string>
<string name="collections_tab_all">Wszystko</string>
<string name="collections_tab_combined">Połączone</string>
</resources>

View file

@ -44,6 +44,22 @@
<string name="web_manage_collections_title">Manage Collections</string>
<string name="web_manage_collections_subtitle">Create and manage your custom collections</string>
<string name="web_status_msg_collections_updated">Your collections have been updated on the TV.</string>
<string name="web_tab_addons">Addons</string>
<string name="web_tab_home_layout">Home Layout</string>
<string name="web_tab_collections">Collections</string>
<string name="web_btn_enable_all">Enable All</string>
<string name="web_btn_disable_all">Disable All</string>
<string name="web_btn_show_all">Show All</string>
<string name="web_btn_hide_all">Hide All</string>
<string name="web_btn_new_collection">+ New Collection</string>
<string name="web_btn_export">Export</string>
<string name="web_btn_import">Import</string>
<string name="web_no_collections">No collections yet</string>
<string name="web_badge_collection">Collection</string>
<string name="web_import_collections_title">Import Collections</string>
<string name="web_import_tab_paste">Paste</string>
<string name="web_import_tab_file">File</string>
<string name="web_import_tab_url">URL</string>
<!-- HomeScreen -->
<string name="home_no_addons">No addons installed. Add one to get started.</string>
@ -1372,10 +1388,13 @@
<string name="cd_loading_logo">Loading logo</string>
<string name="cd_move_up">Move up</string>
<string name="cd_move_down">Move down</string>
<string name="cd_edit">Edit</string>
<string name="cd_delete">Delete</string>
<string name="cd_qr_code">QR Code</string>
<string name="cd_add">Add</string>
<string name="cd_refresh">Refresh</string>
<string name="cd_remove">Remove</string>
<string name="cd_preview">Preview</string>
<string name="cd_test">Test</string>
<string name="cd_unlink">Unlink</string>
<string name="cd_nuvio_logo">NuvioTV</string>
@ -1396,4 +1415,80 @@
<string name="debug_generate_description">Debug-generated %1$s item #%2$d</string>
<string name="debug_generate_result_failed">Failed: %1$s</string>
<!-- Collection Management -->
<string name="collections_header">Collections</string>
<string name="collections_empty">No collections yet. Create one to organize your catalogs.</string>
<string name="collections_new">New Collection</string>
<string name="collections_import">Import</string>
<string name="collections_export_file">Export File</string>
<string name="collections_saved_downloads">Saved to Downloads</string>
<string name="collections_export_failed">Export failed</string>
<string name="collections_import_header">Import Collections</string>
<string name="collections_import_description">Import collections from JSON. Catalogs from addons you don\'t have installed will show a warning.</string>
<string name="collections_cancel">Cancel</string>
<string name="collections_mode_paste">Paste JSON</string>
<string name="collections_mode_file">From File</string>
<string name="collections_mode_url">From URL</string>
<string name="collections_paste_clipboard">Paste from Clipboard</string>
<string name="collections_validate">Validate</string>
<string name="collections_paste_hint">Paste collections JSON here…</string>
<string name="collections_file_description">Reads nuvio-collections.json from the Downloads folder.</string>
<string name="collections_load_file">Load File</string>
<string name="collections_file_loaded">File loaded (%1$d characters)</string>
<string name="collections_fetch_url">Fetch URL</string>
<string name="collections_fetching">Fetching…</string>
<string name="collections_valid_json">Valid JSON</string>
<string name="collections_valid_summary">%1$d collection(s), %2$d folder(s)</string>
<string name="collections_confirm_import">Confirm Import</string>
<string name="collections_folder_count">%1$d folder(s)</string>
<!-- Collection Editor -->
<string name="collections_editor_row_title">Row Title</string>
<string name="collections_editor_save">Save</string>
<string name="collections_editor_backdrop">Backdrop Image</string>
<string name="collections_editor_pin_above">Pin Above Catalogs</string>
<string name="collections_editor_pin_above_desc">Show this collection above all regular home catalogs. Multiple pinned collections follow collection creation order.</string>
<string name="collections_editor_focus_glow">Focus Glow</string>
<string name="collections_editor_focus_glow_desc">Use the native TV glow on this collection\'s home-screen custom catalog cards</string>
<string name="collections_editor_view_mode">View Mode</string>
<string name="collections_editor_show_all_tab">Show \"All\" Tab</string>
<string name="collections_editor_show_all_tab_desc">Combine all catalogs into one tab</string>
<string name="collections_editor_folders">Folders</string>
<string name="collections_editor_add_folder">Add Folder</string>
<string name="collections_editor_edit_folder">Edit Folder</string>
<string name="collections_editor_folder_title">Folder Title</string>
<string name="collections_editor_cover">Cover</string>
<string name="collections_editor_cover_none">None</string>
<string name="collections_editor_cover_emoji">Emoji</string>
<string name="collections_editor_cover_image_url">Image URL</string>
<string name="collections_editor_focus_gif">Focus GIF</string>
<string name="collections_editor_play_gif">Play GIF on Focus</string>
<string name="collections_editor_tile_shape">Tile Shape</string>
<string name="collections_editor_hide_title">Hide Title</string>
<string name="collections_editor_hide_title_desc">Only show the cover image</string>
<string name="collections_editor_display">Display</string>
<string name="collections_editor_catalogs">Catalogs</string>
<string name="collections_editor_add_catalog">Add Catalog</string>
<string name="collections_editor_select_catalogs">Select Catalogs</string>
<string name="collections_editor_done">Done</string>
<string name="collections_editor_choose_emoji">Choose Emoji</string>
<string name="collections_editor_back">Back</string>
<string name="collections_editor_edit_collection">Edit Collection</string>
<string name="collections_editor_catalog_count">%1$d catalog(s)</string>
<string name="collections_editor_view_mode_tabs">Tabs</string>
<string name="collections_editor_view_mode_rows">Rows</string>
<string name="collections_editor_view_mode_follow">Follow Home Layout</string>
<string name="collections_editor_shape_poster">Poster</string>
<string name="collections_editor_shape_wide">Wide</string>
<string name="collections_editor_shape_square">Square</string>
<string name="collections_editor_addon_missing">Addon not installed: %1$s</string>
<string name="collections_editor_placeholder_name">Collection name</string>
<string name="collections_editor_placeholder_backdrop">Backdrop image URL (optional)</string>
<string name="collections_editor_placeholder_folder">Folder name</string>
<string name="collections_editor_placeholder_gif">Animated GIF URL (plays only while focused)</string>
<string name="collections_card_title">Collections</string>
<string name="collections_card_subtitle">Group catalogs into folders on your home screen</string>
<string name="collections_tab_all">All</string>
<string name="collections_tab_combined">Combined</string>
</resources>