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 }