Add a Hide Shorts toggle to the view options

Videos carry isShort from Yattee Server and Piped, and Invidious feed
entries are taken as Shorts when they arrive with no length and aren't
live or upcoming: the feed comes from RSS, which lists Shorts, while
lengths come from the Videos tab, which doesn't. Subscriptions, search
and instance browse hide videos known to be Shorts when the toggle is
on; videos whose source can't tell are always shown.
This commit is contained in:
Arkadiusz Fal
2026-10-11 14:06:15 +02:00
parent 2d3ae1b0e5
commit 8ade5a54d0
10 changed files with 108 additions and 18 deletions

View File

@@ -20765,6 +20765,16 @@
} }
} }
}, },
"viewOptions.hideShorts" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hide Shorts"
}
}
}
},
"viewOptions.hideWatched" : { "viewOptions.hideWatched" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -51,6 +51,9 @@ struct Video: Identifiable, Codable, Sendable {
/// Related/recommended videos (populated from video details API). /// Related/recommended videos (populated from video details API).
let relatedVideos: [Video]? let relatedVideos: [Video]?
/// Whether this is a YouTube Short: `nil` when the source can't tell.
let isShort: Bool?
// MARK: - Explicit Initializer // MARK: - Explicit Initializer
init( init(
@@ -67,7 +70,8 @@ struct Video: Identifiable, Codable, Sendable {
isLive: Bool, isLive: Bool,
isUpcoming: Bool, isUpcoming: Bool,
scheduledStartTime: Date?, scheduledStartTime: Date?,
relatedVideos: [Video]? = nil relatedVideos: [Video]? = nil,
isShort: Bool? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@@ -83,6 +87,7 @@ struct Video: Identifiable, Codable, Sendable {
self.isUpcoming = isUpcoming self.isUpcoming = isUpcoming
self.scheduledStartTime = scheduledStartTime self.scheduledStartTime = scheduledStartTime
self.relatedVideos = relatedVideos self.relatedVideos = relatedVideos
self.isShort = isShort
} }
// MARK: - Standing in for metadata // MARK: - Standing in for metadata
@@ -134,6 +139,12 @@ struct Video: Identifiable, Codable, Sendable {
) )
} }
/// Whether a "Hide Shorts" filter should drop this video: only when the
/// source said it is a Short, never when it couldn't tell.
var isKnownShort: Bool {
isShort == true
}
/// Whether the title is only the video's own ID standing in for one. /// Whether the title is only the video's own ID standing in for one.
/// ///
/// Anything showing this video beside details it fetched itself should /// Anything showing this video beside details it fetched itself should
@@ -518,6 +529,7 @@ extension Video: Equatable {
&& lhs.thumbnails == rhs.thumbnails && lhs.thumbnails == rhs.thumbnails
&& lhs.description == rhs.description && lhs.description == rhs.description
&& lhs.relatedVideos == rhs.relatedVideos && lhs.relatedVideos == rhs.relatedVideos
&& lhs.isShort == rhs.isShort
} }
} }

View File

@@ -592,7 +592,7 @@ actor InvidiousAPI: InstanceAPI {
// Combine notifications and videos arrays - Invidious returns feed items in notifications // Combine notifications and videos arrays - Invidious returns feed items in notifications
let allVideos = (response.notifications ?? []) + response.videos let allVideos = (response.notifications ?? []) + response.videos
let videos = allVideos.map { $0.toVideo(baseURL: instance.url) } let videos = allVideos.map { $0.toVideo(baseURL: instance.url, isShort: $0.isLikelyFeedShort) }
LoggingService.shared.debug( LoggingService.shared.debug(
"Invidious feed: \(response.notifications?.count ?? 0) notifications + \(response.videos.count) videos = \(videos.count) total", "Invidious feed: \(response.notifications?.count ?? 0) notifications + \(response.videos.count) videos = \(videos.count) total",
@@ -1199,7 +1199,18 @@ private struct InvidiousVideo: Decodable, Sendable {
let isUpcoming: Bool? let isUpcoming: Bool?
let premiereTimestamp: Int64? let premiereTimestamp: Int64?
nonisolated func toVideo(baseURL: URL) -> Video { /// Whether this subscription-feed entry is most likely a Short.
///
/// Invidious builds the feed from the channel's RSS, which lists Shorts, and
/// takes each video's length from the channel's Videos tab, which doesn't.
/// A Short therefore arrives with no length, and so does a stream or
/// premiere, which is why those are ruled out. Only meaningful for
/// `/api/v1/auth/feed`: other lists give zero length for other reasons.
var isLikelyFeedShort: Bool {
lengthSeconds == 0 && liveNow != true && isUpcoming != true && premiereTimestamp == nil
}
nonisolated func toVideo(baseURL: URL, isShort: Bool? = nil) -> Video {
let publishedReading = InvidiousSentinel.published( let publishedReading = InvidiousSentinel.published(
published, published,
text: publishedText, text: publishedText,
@@ -1219,7 +1230,8 @@ private struct InvidiousVideo: Decodable, Sendable {
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [], thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
isLive: liveNow ?? false, isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false, isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
isShort: isShort
) )
} }
} }

View File

@@ -884,7 +884,8 @@ private struct PipedVideo: Decodable, Sendable {
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
isLive: duration == -1, isLive: duration == -1,
isUpcoming: false, isUpcoming: false,
scheduledStartTime: nil scheduledStartTime: nil,
isShort: isShort
) )
} }
} }
@@ -1092,6 +1093,7 @@ private struct PipedSearchItem: Decodable, Sendable {
let views: Int64? let views: Int64?
let videos: Int64? let videos: Int64?
let subscribers: Int64? let subscribers: Int64?
let isShort: Bool?
var videoId: String? { var videoId: String? {
url?.replacingOccurrences(of: "/watch?v=", with: "") url?.replacingOccurrences(of: "/watch?v=", with: "")
@@ -1123,7 +1125,8 @@ private struct PipedSearchItem: Decodable, Sendable {
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
isLive: duration == -1, isLive: duration == -1,
isUpcoming: false, isUpcoming: false,
scheduledStartTime: nil scheduledStartTime: nil,
isShort: isShort
) )
} }

View File

@@ -392,6 +392,7 @@ struct ServerFeedVideo: Decodable, Sendable {
let videoUrl: String? let videoUrl: String?
let isUpcoming: Bool? let isUpcoming: Bool?
let premiereTimestamp: Int64? let premiereTimestamp: Int64?
let isShort: Bool?
func toVideo() -> Video? { func toVideo() -> Video? {
// Determine content source based on extractor // Determine content source based on extractor
@@ -418,7 +419,8 @@ struct ServerFeedVideo: Decodable, Sendable {
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [], thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: false, isLive: false,
isUpcoming: isUpcoming ?? false, isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
isShort: isShort
) )
} }
} }
@@ -611,6 +613,7 @@ private struct YatteeVideo: Decodable, Sendable {
let liveNow: Bool? let liveNow: Bool?
let isUpcoming: Bool? let isUpcoming: Bool?
let premiereTimestamp: Int64? let premiereTimestamp: Int64?
let isShort: Bool?
nonisolated func toVideo() -> Video { nonisolated func toVideo() -> Video {
Video( Video(
@@ -626,7 +629,8 @@ private struct YatteeVideo: Decodable, Sendable {
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [], thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: liveNow ?? false, isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false, isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
isShort: isShort
) )
} }
} }

View File

@@ -51,6 +51,9 @@ final class SearchViewModel {
/// Hide watched videos (controlled by view options, not filters) /// Hide watched videos (controlled by view options, not filters)
var hideWatchedVideos: Bool = false var hideWatchedVideos: Bool = false
/// Hide Shorts (controlled by view options, not filters)
var hideShorts: Bool = false
/// Unified result items preserving API order (for InstanceBrowseView style display). /// Unified result items preserving API order (for InstanceBrowseView style display).
private(set) var resultItems: [SearchResultItem] = [] private(set) var resultItems: [SearchResultItem] = []
@@ -161,7 +164,7 @@ final class SearchViewModel {
if resetResults { if resetResults {
// Fresh results // Fresh results
videos = filterWatchedVideos(result.videos) videos = filterHiddenVideos(result.videos)
channels = result.channels channels = result.channels
playlists = result.playlists playlists = result.playlists
@@ -169,8 +172,8 @@ final class SearchViewModel {
resultItems = result.orderedItems.enumerated().compactMap { _, item in resultItems = result.orderedItems.enumerated().compactMap { _, item in
switch item { switch item {
case .video(let video): case .video(let video):
// Skip watched videos if filter is enabled // Skip watched videos and Shorts if those filters are enabled
if hideWatchedVideos && isVideoWatched(video) { if isVideoHidden(video) {
return nil return nil
} }
if let index = videos.firstIndex(where: { $0.id == video.id }) { if let index = videos.firstIndex(where: { $0.id == video.id }) {
@@ -189,7 +192,7 @@ final class SearchViewModel {
let existingChannelIDs = Set(channels.map(\.id)) let existingChannelIDs = Set(channels.map(\.id))
let existingPlaylistIDs = Set(playlists.map(\.id)) let existingPlaylistIDs = Set(playlists.map(\.id))
let filteredNewVideos = filterWatchedVideos(result.videos) let filteredNewVideos = filterHiddenVideos(result.videos)
let newVideos = filteredNewVideos.filter { !existingVideoIDs.contains($0.id) } let newVideos = filteredNewVideos.filter { !existingVideoIDs.contains($0.id) }
let newChannels = result.channels.filter { !existingChannelIDs.contains($0.id) } let newChannels = result.channels.filter { !existingChannelIDs.contains($0.id) }
let newPlaylists = result.playlists.filter { !existingPlaylistIDs.contains($0.id) } let newPlaylists = result.playlists.filter { !existingPlaylistIDs.contains($0.id) }
@@ -203,8 +206,8 @@ final class SearchViewModel {
for item in result.orderedItems { for item in result.orderedItems {
switch item { switch item {
case .video(let video): case .video(let video):
// Skip watched videos if filter is enabled // Skip watched videos and Shorts if those filters are enabled
if hideWatchedVideos && isVideoWatched(video) { if isVideoHidden(video) {
continue continue
} }
guard !existingVideoIDs.contains(video.id) else { continue } guard !existingVideoIDs.contains(video.id) else { continue }
@@ -331,8 +334,14 @@ final class SearchViewModel {
deArrowProvider.prefetch(videoIDs: youtubeIDs) deArrowProvider.prefetch(videoIDs: youtubeIDs)
} }
/// Filters out watched videos if hideWatchedVideos is enabled. /// Filters out watched videos and Shorts when those filters are enabled.
private func filterWatchedVideos(_ videos: [Video]) -> [Video] { private func filterHiddenVideos(_ videos: [Video]) -> [Video] {
var videos = videos
if hideShorts {
videos = videos.filter { !$0.isKnownShort }
}
guard hideWatchedVideos, let dataManager else { guard hideWatchedVideos, let dataManager else {
return videos return videos
} }
@@ -343,6 +352,11 @@ final class SearchViewModel {
return !entry.isFinished return !entry.isFinished
} }
} }
/// Whether the view options hide this video from results.
private func isVideoHidden(_ video: Video) -> Bool {
(hideShorts && video.isKnownShort) || (hideWatchedVideos && isVideoWatched(video))
}
/// Checks if a video is watched (finished). /// Checks if a video is watched (finished).
private func isVideoWatched(_ video: Video) -> Bool { private func isVideoWatched(_ video: Video) -> Bool {

View File

@@ -18,7 +18,10 @@ struct ViewOptionsSheet: View {
/// Optional binding for hide watched toggle (only shown if provided). /// Optional binding for hide watched toggle (only shown if provided).
var hideWatched: Binding<Bool>? var hideWatched: Binding<Bool>?
/// Optional binding for hide shorts toggle (only shown if provided).
var hideShorts: Binding<Bool>?
/// Optional binding for channel strip size (only shown if provided). /// Optional binding for channel strip size (only shown if provided).
var channelStripSize: Binding<ChannelStripSize>? var channelStripSize: Binding<ChannelStripSize>?
@@ -74,6 +77,10 @@ struct ViewOptionsSheet: View {
Toggle("viewOptions.hideWatched", isOn: hideWatched) Toggle("viewOptions.hideWatched", isOn: hideWatched)
} }
if let hideShorts = hideShorts {
Toggle("viewOptions.hideShorts", isOn: hideShorts)
}
if let channelStripSize = channelStripSize { if let channelStripSize = channelStripSize {
PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) { PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) {
ForEach(ChannelStripSize.allCases, id: \.self) { size in ForEach(ChannelStripSize.allCases, id: \.self) { size in
@@ -140,6 +147,10 @@ struct ViewOptionsSheet: View {
Toggle("viewOptions.hideWatched", isOn: hideWatched) Toggle("viewOptions.hideWatched", isOn: hideWatched)
} }
if let hideShorts = hideShorts {
Toggle("viewOptions.hideShorts", isOn: hideShorts)
}
// Channel Strip (subscriptions only) // Channel Strip (subscriptions only)
if let channelStripSize = channelStripSize { if let channelStripSize = channelStripSize {
PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) { PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) {

View File

@@ -50,6 +50,7 @@ struct InstanceBrowseView: View {
@AppStorage var rowStyle: VideoRowStyle @AppStorage var rowStyle: VideoRowStyle
@AppStorage var gridColumnCount: Int @AppStorage var gridColumnCount: Int
@AppStorage var hideWatched: Bool @AppStorage var hideWatched: Bool
@AppStorage var hideShorts: Bool
// View options UI state // View options UI state
@State private var showViewOptions = false @State private var showViewOptions = false
@@ -74,6 +75,7 @@ struct InstanceBrowseView: View {
_rowStyle = AppStorage(wrappedValue: .regular, "instanceBrowse.\(instance.id).rowStyle") _rowStyle = AppStorage(wrappedValue: .regular, "instanceBrowse.\(instance.id).rowStyle")
_gridColumnCount = AppStorage(wrappedValue: 2, "instanceBrowse.\(instance.id).gridColumns") _gridColumnCount = AppStorage(wrappedValue: 2, "instanceBrowse.\(instance.id).gridColumns")
_hideWatched = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideWatched") _hideWatched = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideWatched")
_hideShorts = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideShorts")
} }
/// List style from centralized settings. /// List style from centralized settings.
@@ -126,6 +128,7 @@ struct InstanceBrowseView: View {
rowStyle: $rowStyle, rowStyle: $rowStyle,
gridColumns: $gridColumnCount, gridColumns: $gridColumnCount,
hideWatched: $hideWatched, hideWatched: $hideWatched,
hideShorts: $hideShorts,
maxGridColumns: gridConfig.maxColumns maxGridColumns: gridConfig.maxColumns
) )
} }
@@ -826,6 +829,10 @@ struct InstanceBrowseView: View {
return !entry.isFinished return !entry.isFinished
} }
} }
if hideShorts {
videos = videos.filter { !$0.isKnownShort }
}
return videos return videos
} }

View File

@@ -50,6 +50,7 @@ struct SearchView: View {
@AppStorage("searchRowStyle") private var rowStyle: VideoRowStyle = .regular @AppStorage("searchRowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("searchGridColumns") private var gridColumns = 2 @AppStorage("searchGridColumns") private var gridColumns = 2
@AppStorage("searchHideWatched") private var hideWatched = false @AppStorage("searchHideWatched") private var hideWatched = false
@AppStorage("searchHideShorts") private var hideShorts = false
/// List style from centralized settings. /// List style from centralized settings.
private var listStyle: VideoListStyle { private var listStyle: VideoListStyle {
@@ -172,6 +173,9 @@ struct SearchView: View {
.onChange(of: hideWatched) { _, newValue in .onChange(of: hideWatched) { _, newValue in
searchViewModel?.hideWatchedVideos = newValue searchViewModel?.hideWatchedVideos = newValue
} }
.onChange(of: hideShorts) { _, newValue in
searchViewModel?.hideShorts = newValue
}
.onChange(of: saveRecentSearches) { _, _ in .onChange(of: saveRecentSearches) { _, _ in
loadSearchHistory() loadSearchHistory()
} }
@@ -203,6 +207,7 @@ struct SearchView: View {
rowStyle: $rowStyle, rowStyle: $rowStyle,
gridColumns: $gridColumns, gridColumns: $gridColumns,
hideWatched: $hideWatched, hideWatched: $hideWatched,
hideShorts: $hideShorts,
maxGridColumns: gridConfig.maxColumns maxGridColumns: gridConfig.maxColumns
) )
} }
@@ -389,6 +394,7 @@ struct SearchView: View {
settingsManager: appEnvironment.settingsManager settingsManager: appEnvironment.settingsManager
) )
searchViewModel?.hideWatchedVideos = hideWatched searchViewModel?.hideWatchedVideos = hideWatched
searchViewModel?.hideShorts = hideShorts
loadFilters() loadFilters()
loadSearchHistory() loadSearchHistory()
loadRecentChannels() loadRecentChannels()
@@ -410,6 +416,7 @@ struct SearchView: View {
settingsManager: appEnvironment.settingsManager settingsManager: appEnvironment.settingsManager
) )
searchViewModel?.hideWatchedVideos = hideWatched searchViewModel?.hideWatchedVideos = hideWatched
searchViewModel?.hideShorts = hideShorts
loadFilters() loadFilters()
// Re-run search if we had results // Re-run search if we had results

View File

@@ -44,6 +44,7 @@ struct SubscriptionsView: View {
@AppStorage("subscriptionsRowStyle") private var rowStyle: VideoRowStyle = .regular @AppStorage("subscriptionsRowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("subscriptionsGridColumns") private var gridColumns = 2 @AppStorage("subscriptionsGridColumns") private var gridColumns = 2
@AppStorage("subscriptionsHideWatched") private var hideWatched = false @AppStorage("subscriptionsHideWatched") private var hideWatched = false
@AppStorage("subscriptionsHideShorts") private var hideShorts = false
@AppStorage("subscriptionsChannelStripSize") private var channelStripSize: ChannelStripSize = .normal @AppStorage("subscriptionsChannelStripSize") private var channelStripSize: ChannelStripSize = .normal
@AppStorage("subscriptionsShowSidebar") private var showSidebar = true @AppStorage("subscriptionsShowSidebar") private var showSidebar = true
/// Whether the last completed load produced enough channels for the sidebar, /// Whether the last completed load produced enough channels for the sidebar,
@@ -126,8 +127,13 @@ struct SubscriptionsView: View {
Text("common.on").tag(true) Text("common.on").tag(true)
Text("common.off").tag(false) Text("common.off").tag(false)
} }
PlatformMenuPicker(String(localized: "viewOptions.hideShorts"), selection: $hideShorts) {
Text("common.on").tag(true)
Text("common.off").tag(false)
}
#else #else
Toggle("viewOptions.hideWatched", isOn: $hideWatched) Toggle("viewOptions.hideWatched", isOn: $hideWatched)
Toggle("viewOptions.hideShorts", isOn: $hideShorts)
#endif #endif
#if os(iOS) #if os(iOS)
@@ -245,7 +251,7 @@ struct SubscriptionsView: View {
.joined(separator: "|") .joined(separator: "|")
} }
/// Videos filtered by selected channel and watch status. /// Videos filtered by selected channel, watch status and Shorts.
private var filteredVideos: [Video] { private var filteredVideos: [Video] {
var videos = feedCache.videos var videos = feedCache.videos
@@ -253,6 +259,10 @@ struct SubscriptionsView: View {
videos = videos.filter { $0.author.id == channelID } videos = videos.filter { $0.author.id == channelID }
} }
if hideShorts {
videos = videos.filter { !$0.isKnownShort }
}
if hideWatched { if hideWatched {
videos = videos.filter { video in videos = videos.filter { video in
guard let entry = watchEntriesMap[video.id.videoID] else { return true } guard let entry = watchEntriesMap[video.id.videoID] else { return true }