From 8ade5a54d004d508ce458e327cde2e3f671ffc9d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 11 Oct 2026 14:06:15 +0200 Subject: [PATCH] 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. --- Yattee/Localizable.xcstrings | 10 +++++++ Yattee/Models/Video.swift | 14 ++++++++- Yattee/Services/API/InvidiousAPI.swift | 18 +++++++++-- Yattee/Services/API/PipedAPI.swift | 7 +++-- Yattee/Services/API/YatteeServerAPI.swift | 8 +++-- Yattee/ViewModels/SearchViewModel.swift | 30 ++++++++++++++----- .../Views/Components/ViewOptionsSheet.swift | 13 +++++++- .../Views/Instances/InstanceBrowseView.swift | 7 +++++ Yattee/Views/Search/SearchView.swift | 7 +++++ .../Subscriptions/SubscriptionsView.swift | 12 +++++++- 10 files changed, 108 insertions(+), 18 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 724bd90c..527367b5 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -20765,6 +20765,16 @@ } } }, + "viewOptions.hideShorts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Shorts" + } + } + } + }, "viewOptions.hideWatched" : { "localizations" : { "en" : { diff --git a/Yattee/Models/Video.swift b/Yattee/Models/Video.swift index 841e79b0..c095a520 100644 --- a/Yattee/Models/Video.swift +++ b/Yattee/Models/Video.swift @@ -51,6 +51,9 @@ struct Video: Identifiable, Codable, Sendable { /// Related/recommended videos (populated from video details API). let relatedVideos: [Video]? + /// Whether this is a YouTube Short: `nil` when the source can't tell. + let isShort: Bool? + // MARK: - Explicit Initializer init( @@ -67,7 +70,8 @@ struct Video: Identifiable, Codable, Sendable { isLive: Bool, isUpcoming: Bool, scheduledStartTime: Date?, - relatedVideos: [Video]? = nil + relatedVideos: [Video]? = nil, + isShort: Bool? = nil ) { self.id = id self.title = title @@ -83,6 +87,7 @@ struct Video: Identifiable, Codable, Sendable { self.isUpcoming = isUpcoming self.scheduledStartTime = scheduledStartTime self.relatedVideos = relatedVideos + self.isShort = isShort } // 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. /// /// Anything showing this video beside details it fetched itself should @@ -518,6 +529,7 @@ extension Video: Equatable { && lhs.thumbnails == rhs.thumbnails && lhs.description == rhs.description && lhs.relatedVideos == rhs.relatedVideos + && lhs.isShort == rhs.isShort } } diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index f8252648..82127dc2 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -592,7 +592,7 @@ actor InvidiousAPI: InstanceAPI { // Combine notifications and videos arrays - Invidious returns feed items in notifications 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( "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 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( published, text: publishedText, @@ -1219,7 +1230,8 @@ private struct InvidiousVideo: Decodable, Sendable { thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [], isLive: liveNow ?? false, isUpcoming: isUpcoming ?? false, - scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } + scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + isShort: isShort ) } } diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index cffd3e87..549fd50f 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -884,7 +884,8 @@ private struct PipedVideo: Decodable, Sendable { thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), isLive: duration == -1, isUpcoming: false, - scheduledStartTime: nil + scheduledStartTime: nil, + isShort: isShort ) } } @@ -1092,6 +1093,7 @@ private struct PipedSearchItem: Decodable, Sendable { let views: Int64? let videos: Int64? let subscribers: Int64? + let isShort: Bool? var videoId: String? { url?.replacingOccurrences(of: "/watch?v=", with: "") @@ -1123,7 +1125,8 @@ private struct PipedSearchItem: Decodable, Sendable { thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), isLive: duration == -1, isUpcoming: false, - scheduledStartTime: nil + scheduledStartTime: nil, + isShort: isShort ) } diff --git a/Yattee/Services/API/YatteeServerAPI.swift b/Yattee/Services/API/YatteeServerAPI.swift index 76a13401..9776cc30 100644 --- a/Yattee/Services/API/YatteeServerAPI.swift +++ b/Yattee/Services/API/YatteeServerAPI.swift @@ -392,6 +392,7 @@ struct ServerFeedVideo: Decodable, Sendable { let videoUrl: String? let isUpcoming: Bool? let premiereTimestamp: Int64? + let isShort: Bool? func toVideo() -> Video? { // Determine content source based on extractor @@ -418,7 +419,8 @@ struct ServerFeedVideo: Decodable, Sendable { thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [], isLive: 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 isUpcoming: Bool? let premiereTimestamp: Int64? + let isShort: Bool? nonisolated func toVideo() -> Video { Video( @@ -626,7 +629,8 @@ private struct YatteeVideo: Decodable, Sendable { thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [], isLive: liveNow ?? false, isUpcoming: isUpcoming ?? false, - scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } + scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + isShort: isShort ) } } diff --git a/Yattee/ViewModels/SearchViewModel.swift b/Yattee/ViewModels/SearchViewModel.swift index a16fba22..4eae833c 100644 --- a/Yattee/ViewModels/SearchViewModel.swift +++ b/Yattee/ViewModels/SearchViewModel.swift @@ -51,6 +51,9 @@ final class SearchViewModel { /// Hide watched videos (controlled by view options, not filters) 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). private(set) var resultItems: [SearchResultItem] = [] @@ -161,7 +164,7 @@ final class SearchViewModel { if resetResults { // Fresh results - videos = filterWatchedVideos(result.videos) + videos = filterHiddenVideos(result.videos) channels = result.channels playlists = result.playlists @@ -169,8 +172,8 @@ final class SearchViewModel { resultItems = result.orderedItems.enumerated().compactMap { _, item in switch item { case .video(let video): - // Skip watched videos if filter is enabled - if hideWatchedVideos && isVideoWatched(video) { + // Skip watched videos and Shorts if those filters are enabled + if isVideoHidden(video) { return nil } if let index = videos.firstIndex(where: { $0.id == video.id }) { @@ -189,7 +192,7 @@ final class SearchViewModel { let existingChannelIDs = Set(channels.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 newChannels = result.channels.filter { !existingChannelIDs.contains($0.id) } let newPlaylists = result.playlists.filter { !existingPlaylistIDs.contains($0.id) } @@ -203,8 +206,8 @@ final class SearchViewModel { for item in result.orderedItems { switch item { case .video(let video): - // Skip watched videos if filter is enabled - if hideWatchedVideos && isVideoWatched(video) { + // Skip watched videos and Shorts if those filters are enabled + if isVideoHidden(video) { continue } guard !existingVideoIDs.contains(video.id) else { continue } @@ -331,8 +334,14 @@ final class SearchViewModel { deArrowProvider.prefetch(videoIDs: youtubeIDs) } - /// Filters out watched videos if hideWatchedVideos is enabled. - private func filterWatchedVideos(_ videos: [Video]) -> [Video] { + /// Filters out watched videos and Shorts when those filters are enabled. + private func filterHiddenVideos(_ videos: [Video]) -> [Video] { + var videos = videos + + if hideShorts { + videos = videos.filter { !$0.isKnownShort } + } + guard hideWatchedVideos, let dataManager else { return videos } @@ -343,6 +352,11 @@ final class SearchViewModel { 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). private func isVideoWatched(_ video: Video) -> Bool { diff --git a/Yattee/Views/Components/ViewOptionsSheet.swift b/Yattee/Views/Components/ViewOptionsSheet.swift index a1be0555..0f035020 100644 --- a/Yattee/Views/Components/ViewOptionsSheet.swift +++ b/Yattee/Views/Components/ViewOptionsSheet.swift @@ -18,7 +18,10 @@ struct ViewOptionsSheet: View { /// Optional binding for hide watched toggle (only shown if provided). var hideWatched: Binding? - + + /// Optional binding for hide shorts toggle (only shown if provided). + var hideShorts: Binding? + /// Optional binding for channel strip size (only shown if provided). var channelStripSize: Binding? @@ -74,6 +77,10 @@ struct ViewOptionsSheet: View { Toggle("viewOptions.hideWatched", isOn: hideWatched) } + if let hideShorts = hideShorts { + Toggle("viewOptions.hideShorts", isOn: hideShorts) + } + if let channelStripSize = channelStripSize { PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) { ForEach(ChannelStripSize.allCases, id: \.self) { size in @@ -140,6 +147,10 @@ struct ViewOptionsSheet: View { Toggle("viewOptions.hideWatched", isOn: hideWatched) } + if let hideShorts = hideShorts { + Toggle("viewOptions.hideShorts", isOn: hideShorts) + } + // Channel Strip (subscriptions only) if let channelStripSize = channelStripSize { PlatformMenuPicker(String(localized: "viewOptions.channelStrip"), selection: channelStripSize) { diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index 0599bfe5..6b1767e0 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -50,6 +50,7 @@ struct InstanceBrowseView: View { @AppStorage var rowStyle: VideoRowStyle @AppStorage var gridColumnCount: Int @AppStorage var hideWatched: Bool + @AppStorage var hideShorts: Bool // View options UI state @State private var showViewOptions = false @@ -74,6 +75,7 @@ struct InstanceBrowseView: View { _rowStyle = AppStorage(wrappedValue: .regular, "instanceBrowse.\(instance.id).rowStyle") _gridColumnCount = AppStorage(wrappedValue: 2, "instanceBrowse.\(instance.id).gridColumns") _hideWatched = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideWatched") + _hideShorts = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideShorts") } /// List style from centralized settings. @@ -126,6 +128,7 @@ struct InstanceBrowseView: View { rowStyle: $rowStyle, gridColumns: $gridColumnCount, hideWatched: $hideWatched, + hideShorts: $hideShorts, maxGridColumns: gridConfig.maxColumns ) } @@ -826,6 +829,10 @@ struct InstanceBrowseView: View { return !entry.isFinished } } + + if hideShorts { + videos = videos.filter { !$0.isKnownShort } + } return videos } diff --git a/Yattee/Views/Search/SearchView.swift b/Yattee/Views/Search/SearchView.swift index 9ccd1a69..64ac057c 100644 --- a/Yattee/Views/Search/SearchView.swift +++ b/Yattee/Views/Search/SearchView.swift @@ -50,6 +50,7 @@ struct SearchView: View { @AppStorage("searchRowStyle") private var rowStyle: VideoRowStyle = .regular @AppStorage("searchGridColumns") private var gridColumns = 2 @AppStorage("searchHideWatched") private var hideWatched = false + @AppStorage("searchHideShorts") private var hideShorts = false /// List style from centralized settings. private var listStyle: VideoListStyle { @@ -172,6 +173,9 @@ struct SearchView: View { .onChange(of: hideWatched) { _, newValue in searchViewModel?.hideWatchedVideos = newValue } + .onChange(of: hideShorts) { _, newValue in + searchViewModel?.hideShorts = newValue + } .onChange(of: saveRecentSearches) { _, _ in loadSearchHistory() } @@ -203,6 +207,7 @@ struct SearchView: View { rowStyle: $rowStyle, gridColumns: $gridColumns, hideWatched: $hideWatched, + hideShorts: $hideShorts, maxGridColumns: gridConfig.maxColumns ) } @@ -389,6 +394,7 @@ struct SearchView: View { settingsManager: appEnvironment.settingsManager ) searchViewModel?.hideWatchedVideos = hideWatched + searchViewModel?.hideShorts = hideShorts loadFilters() loadSearchHistory() loadRecentChannels() @@ -410,6 +416,7 @@ struct SearchView: View { settingsManager: appEnvironment.settingsManager ) searchViewModel?.hideWatchedVideos = hideWatched + searchViewModel?.hideShorts = hideShorts loadFilters() // Re-run search if we had results diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index 65895850..637e2f0f 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -44,6 +44,7 @@ struct SubscriptionsView: View { @AppStorage("subscriptionsRowStyle") private var rowStyle: VideoRowStyle = .regular @AppStorage("subscriptionsGridColumns") private var gridColumns = 2 @AppStorage("subscriptionsHideWatched") private var hideWatched = false + @AppStorage("subscriptionsHideShorts") private var hideShorts = false @AppStorage("subscriptionsChannelStripSize") private var channelStripSize: ChannelStripSize = .normal @AppStorage("subscriptionsShowSidebar") private var showSidebar = true /// 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.off").tag(false) } + PlatformMenuPicker(String(localized: "viewOptions.hideShorts"), selection: $hideShorts) { + Text("common.on").tag(true) + Text("common.off").tag(false) + } #else Toggle("viewOptions.hideWatched", isOn: $hideWatched) + Toggle("viewOptions.hideShorts", isOn: $hideShorts) #endif #if os(iOS) @@ -245,7 +251,7 @@ struct SubscriptionsView: View { .joined(separator: "|") } - /// Videos filtered by selected channel and watch status. + /// Videos filtered by selected channel, watch status and Shorts. private var filteredVideos: [Video] { var videos = feedCache.videos @@ -253,6 +259,10 @@ struct SubscriptionsView: View { videos = videos.filter { $0.author.id == channelID } } + if hideShorts { + videos = videos.filter { !$0.isKnownShort } + } + if hideWatched { videos = videos.filter { video in guard let entry = watchEntriesMap[video.id.videoID] else { return true }