From 321eaecd21cac24b311c8049a47fe7bb6233b9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Tue, 23 Apr 2024 11:00:27 +0200 Subject: [PATCH 1/3] SponsorBlock align categories with upstream --- Model/SponsorBlock/SponsorBlockAPI.swift | 38 ++++++++++++++-------- Shared/Settings/SponsorBlockSettings.swift | 5 +-- Shared/en.lproj/Localizable.strings | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/Model/SponsorBlock/SponsorBlockAPI.swift b/Model/SponsorBlock/SponsorBlockAPI.swift index c8517259..4df4ca4e 100644 --- a/Model/SponsorBlock/SponsorBlockAPI.swift +++ b/Model/SponsorBlock/SponsorBlockAPI.swift @@ -5,7 +5,7 @@ import Logging import SwiftyJSON final class SponsorBlockAPI: ObservableObject { - static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"] + static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"] let logger = Logger(label: "stream.yattee.app.sb") @@ -21,15 +21,19 @@ final class SponsorBlockAPI: ObservableObject { case "sponsor": return "Sponsor".localized() case "selfpromo": - return "Self-promotion".localized() - case "intro": - return "Intro".localized() - case "outro": - return "Outro".localized() + return "Unpaid/Self Promotion".localized() case "interaction": - return "Interaction".localized() + return "Interaction Reminder (Subscribe)".localized() + case "intro": + return "Intermission/Intro Animation".localized() + case "outro": + return "Endcards/Credits".localized() + case "preview": + return "Preview/Recap/Hook".localized() + case "filler": + return "Filler Tangent/Jokes".localized() case "music_offtopic": - return "Offtopic in Music Videos".localized() + return "Music: Non-Music Section".localized() default: return name.capitalized } @@ -46,9 +50,14 @@ final class SponsorBlockAPI: ObservableObject { "The creator will receive payment or compensation in the form of money or free products.").localized() case "selfpromo": - return ("Promoting a product or service that is directly related to the creator themselves. " + + return ("The creator will not receive any payment in exchange for this promotion. " + + "This includes charity drives or free shout outs for products or other people they like.\n\n" + + "Promoting a product or service that is directly related to the creator themselves. " + "This usually includes merchandise or promotion of monetized platforms.").localized() + case "interaction": + return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized() + case "intro": return ("Segments typically found at the start of a video that include an animation, " + "still frame or clip which are also seen in other videos by the same creator.").localized() @@ -56,8 +65,11 @@ final class SponsorBlockAPI: ObservableObject { case "outro": return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized() - case "interaction": - return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized() + case "preview": + return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized() + + case "filler": + return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized() case "music_offtopic": return "For videos which feature music as the primary content.".localized() @@ -100,8 +112,8 @@ final class SponsorBlockAPI: ObservableObject { self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end } self.logger.info("loaded \(self.segments.count) SponsorBlock segments") - self.segments.forEach { - self.logger.info("\($0.start) -> \($0.end)") + for segment in self.segments { + self.logger.info("\(segment.start) -> \(segment.end)") } case let .failure(error): self.segments = [] diff --git a/Shared/Settings/SponsorBlockSettings.swift b/Shared/Settings/SponsorBlockSettings.swift index 114a0154..bd7c677a 100644 --- a/Shared/Settings/SponsorBlockSettings.swift +++ b/Shared/Settings/SponsorBlockSettings.swift @@ -79,17 +79,18 @@ struct SponsorBlockSettings: View { ForEach(SponsorBlockAPI.categories, id: \.self) { category in Text(SponsorBlockAPI.categoryDescription(category) ?? "Category") .fontWeight(.bold) + .padding(.bottom, 0.5) #if os(tvOS) .focusable() #endif Text(SponsorBlockAPI.categoryDetails(category) ?? "Details") - .padding(.bottom, 3) + .padding(.bottom, 10) .fixedSize(horizontal: false, vertical: true) } } .foregroundColor(.secondary) - .padding(.top, 3) + .padding(.top, 10) } func toggleCategory(_ category: String, value: Bool) { diff --git a/Shared/en.lproj/Localizable.strings b/Shared/en.lproj/Localizable.strings index f4930fc7..238e7ecc 100644 --- a/Shared/en.lproj/Localizable.strings +++ b/Shared/en.lproj/Localizable.strings @@ -108,7 +108,7 @@ "Enter fullscreen in landscape" = "Enter fullscreen in landscape"; "Error" = "Error"; "Error when accessing playlist" = "Error when accessing playlist"; -"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).\n"; +"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)."; "Favorites" = "Favorites"; "Filter" = "Filter"; "Filter: active" = "Filter: active"; From b5ac760af26ab471811132ed83d946b2091c521f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Tue, 23 Apr 2024 17:16:31 +0200 Subject: [PATCH 2/3] SponsorBlock set colors for each category Default colors are defined in alignment to upstream. These can be changed by the user. The colors are also used in the Timeline and the seek view. --- Shared/Defaults.swift | 24 ++++ Shared/Player/Controls/OSD/Seek.swift | 17 ++- Shared/Player/Controls/TimelineView.swift | 15 ++- Shared/Settings/SponsorBlockSettings.swift | 122 +++++++++++++++------ 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 7df4c47d..160a5b6e 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -243,6 +243,7 @@ extension Defaults.Keys { static let sponsorBlockInstance = Key("sponsorBlockInstance", default: "https://sponsor.ajay.app") static let sponsorBlockCategories = Key>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories)) + static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary) // MARK: GROUP - Locations @@ -580,3 +581,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable { case horizontalCells case list } + +enum SponsorBlockColors: String { + case sponsor = "#00D400" // Green + case selfpromo = "#FFFF00" // Yellow + case interaction = "#CC00FF" // Purple + case intro = "#00FFFF" // Cyan + case outro = "#0202ED" // Dark Blue + case preview = "#008FD6" // Light Blue + case filler = "#7300FF" // Violet + case music_offtopic = "#FF9900" // Orange + + // Define all cases, can be used to iterate over the colors + static let allCases: [SponsorBlockColors] = [.sponsor, .selfpromo, .interaction, .intro, .outro, .preview, .filler, .music_offtopic] + + // Create a dictionary with the category names as keys and colors as values + static let dictionary: [String: String] = { + var dict = [String: String]() + for item in allCases { + dict[String(describing: item)] = item.rawValue + } + return dict + }() +} diff --git a/Shared/Player/Controls/OSD/Seek.swift b/Shared/Player/Controls/OSD/Seek.swift index 3813c7f7..3f102621 100644 --- a/Shared/Player/Controls/OSD/Seek.swift +++ b/Shared/Player/Controls/OSD/Seek.swift @@ -13,6 +13,17 @@ struct Seek: View { @Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout + @Default(.sponsorBlockColors) private var sponsorBlockColors + + private func getColor(for category: String) -> Color { + if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) { + let r = Double((rgbValue >> 16) & 0xFF) / 255.0 + let g = Double((rgbValue >> 8) & 0xFF) / 255.0 + let b = Double(rgbValue & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + return Color("AppRedColor") // Fallback color if no match found + } var body: some View { Group { @@ -51,7 +62,8 @@ struct Seek: View { if let segment = projectedSegment { Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor") .font(.system(size: playerControlsLayout.segmentFontSize)) - .foregroundColor(Color("AppRedColor")) + .foregroundColor(getColor(for: segment.category)) + .padding(.bottom, 3) } } else { #if !os(tvOS) @@ -69,7 +81,8 @@ struct Seek: View { Divider() Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor") .font(.system(size: playerControlsLayout.segmentFontSize)) - .foregroundColor(Color("AppRedColor")) + .foregroundColor(getColor(for: category)) + .padding(.bottom, 3) default: EmptyView() } diff --git a/Shared/Player/Controls/TimelineView.swift b/Shared/Player/Controls/TimelineView.swift index 7f39f4c5..84508ccf 100644 --- a/Shared/Player/Controls/TimelineView.swift +++ b/Shared/Player/Controls/TimelineView.swift @@ -51,11 +51,22 @@ struct TimelineView: View { @Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout + @Default(.sponsorBlockColors) private var sponsorBlockColors var playerControlsLayout: PlayerControlsLayout { player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout } + private func getColor(for category: String) -> Color { + if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) { + let r = Double((rgbValue >> 16) & 0xFF) / 255.0 + let g = Double((rgbValue >> 8) & 0xFF) / 255.0 + let b = Double(rgbValue & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + return Color("AppRedColor") // Fallback color if no match found + } + var chapters: [Chapter] { player.currentVideo?.chapters ?? [] } @@ -79,7 +90,7 @@ struct TimelineView: View { Text(description) .font(.system(size: playerControlsLayout.segmentFontSize)) .fixedSize() - .foregroundColor(Color("AppRedColor")) + .foregroundColor(getColor(for: segment.category)) } if let chapter = projectedChapter { Text(chapter.title) @@ -299,7 +310,7 @@ struct TimelineView: View { ForEach(segments, id: \.uuid) { segment in Rectangle() .offset(x: segmentLayerHorizontalOffset(segment)) - .foregroundColor(Color("AppRedColor")) + .foregroundColor(getColor(for: segment.category)) .frame(maxHeight: height) .frame(width: segmentLayerWidth(segment)) } diff --git a/Shared/Settings/SponsorBlockSettings.swift b/Shared/Settings/SponsorBlockSettings.swift index bd7c677a..f51a887a 100644 --- a/Shared/Settings/SponsorBlockSettings.swift +++ b/Shared/Settings/SponsorBlockSettings.swift @@ -1,15 +1,18 @@ import Defaults import SwiftUI +import UIKit struct SponsorBlockSettings: View { + @ObservedObject private var settings = SettingsModel.shared + @Default(.sponsorBlockInstance) private var sponsorBlockInstance @Default(.sponsorBlockCategories) private var sponsorBlockCategories + @Default(.sponsorBlockColors) private var sponsorBlockColors var body: some View { Group { #if os(macOS) sections - Spacer() #else List { @@ -35,41 +38,63 @@ struct SponsorBlockSettings: View { .labelsHidden() #if !os(macOS) .autocapitalization(.none) + .disableAutocorrection(true) .keyboardType(.URL) #endif } + Section(header: SettingsHeader(text: "Categories to Skip".localized())) { + categoryRows + } + colorSection - Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) { - #if os(macOS) - let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in - MultiselectRow( - title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown", - selected: sponsorBlockCategories.contains(category) - ) { value in - toggleCategory(category, value: value) - } - } + Button { + settings.presentAlert( + Alert( + title: Text("Restore Default Colors?"), + message: Text("This action will reset all custom colors back to their original defaults. " + + "Any custom color changes you've made will be lost."), + primaryButton: .destructive(Text("Restore")) { + resetColors() + }, + secondaryButton: .cancel() + ) + ) + } label: { + Text("Restore Default Colors …") + .foregroundColor(.red) + } - Group { - if #available(macOS 12.0, *) { - list - .listStyle(.inset(alternatesRowBackgrounds: true)) - } else { - list - .listStyle(.inset) - } - } - Spacer() - #else - ForEach(SponsorBlockAPI.categories, id: \.self) { category in - MultiselectRow( - title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown", - selected: sponsorBlockCategories.contains(category) - ) { value in - toggleCategory(category, value: value) - } - } - #endif + Section(footer: categoriesDetails) { + EmptyView() + } + } + } + + private var colorSection: some View { + Section(header: SettingsHeader(text: "Colors for Categories")) { + ForEach(SponsorBlockAPI.categories, id: \.self) { category in + LazyVStack(alignment: .leading) { + ColorPicker( + SponsorBlockAPI.categoryDescription(category) ?? "Unknown", + selection: Binding( + get: { getColor(for: category) }, + set: { setColor($0, for: category) } + ) + ) + } + } + } + } + + private var categoryRows: some View { + ForEach(SponsorBlockAPI.categories, id: \.self) { category in + LazyVStack(alignment: .leading) { + MultiselectRow( + title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown", + selected: sponsorBlockCategories.contains(category) + ) { value in + toggleCategory(category, value: value) + } } } } @@ -90,7 +115,6 @@ struct SponsorBlockSettings: View { } } .foregroundColor(.secondary) - .padding(.top, 10) } func toggleCategory(_ category: String, value: Bool) { @@ -100,6 +124,40 @@ struct SponsorBlockSettings: View { sponsorBlockCategories.insert(category) } } + + private func getColor(for category: String) -> Color { + if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) { + let r = Double((rgbValue >> 16) & 0xFF) / 255.0 + let g = Double((rgbValue >> 8) & 0xFF) / 255.0 + let b = Double(rgbValue & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + return Color("AppRedColor") // Fallback color if no match found + } + + private func setColor(_ color: Color, for category: String) { + let uiColor = UIColor(color) + + // swiftlint:disable no_cgfloat + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + // swiftlint:enable no_cgfloat + + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let r = Int(red * 255.0) + let g = Int(green * 255.0) + let b = Int(blue * 255.0) + + let rgbValue = (r << 16) | (g << 8) | b + sponsorBlockColors[category] = String(format: "#%06x", rgbValue) + } + + private func resetColors() { + sponsorBlockColors = SponsorBlockColors.dictionary + } } struct SponsorBlockSettings_Previews: PreviewProvider { From ae65acdd16ec751512c08a9fa35822b39ed2a931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Tue, 23 Apr 2024 22:08:08 +0200 Subject: [PATCH 3/3] Interface tweaks for SponsorBlock during playback Show/Hide categories in timeline. Show/Hide notice after skip. Show adjusted or actual total time. --- Model/SeekModel.swift | 4 ++-- Shared/Defaults.swift | 3 +++ Shared/Player/Controls/OSD/Seek.swift | 3 +++ Shared/Player/Controls/TimelineView.swift | 26 +++++++++++++--------- Shared/Settings/SponsorBlockSettings.swift | 10 +++++++++ 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/Model/SeekModel.swift b/Model/SeekModel.swift index 888cff0b..613da00e 100644 --- a/Model/SeekModel.swift +++ b/Model/SeekModel.swift @@ -71,13 +71,13 @@ final class SeekModel: ObservableObject { func showOSD() { guard !presentingOSD else { return } - withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true } + presentingOSD = true } func hideOSD() { guard presentingOSD else { return } - withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false } + presentingOSD = false } func hideOSDWithDelay() { diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 160a5b6e..4e8499e8 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -244,6 +244,9 @@ extension Defaults.Keys { static let sponsorBlockInstance = Key("sponsorBlockInstance", default: "https://sponsor.ajay.app") static let sponsorBlockCategories = Key>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories)) static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary) + static let sponsorBlockShowTimeWithSkipsRemoved = Key("sponsorBlockShowTimeWithSkipsRemoved", default: false) + static let sponsorBlockShowCategoriesInTimeline = Key("sponsorBlockShowCategoriesInTimeline", default: true) + static let sponsorBlockShowNoticeAfterSkip = Key("sponsorBlockShowNoticeAfterSkip", default: true) // MARK: GROUP - Locations diff --git a/Shared/Player/Controls/OSD/Seek.swift b/Shared/Player/Controls/OSD/Seek.swift index 3f102621..f729a9a0 100644 --- a/Shared/Player/Controls/OSD/Seek.swift +++ b/Shared/Player/Controls/OSD/Seek.swift @@ -14,6 +14,7 @@ struct Seek: View { @Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout @Default(.sponsorBlockColors) private var sponsorBlockColors + @Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip private func getColor(for category: String) -> Color { if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) { @@ -36,6 +37,7 @@ struct Seek: View { #endif } .opacity(visible || YatteeApp.isForPreviews ? 1 : 0) + .animation(.easeIn) } var content: some View { @@ -130,6 +132,7 @@ struct Seek: View { var visible: Bool { guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false } if let type = model.lastSeekType, !type.presentable { return false } + if !showNoticeAfterSkip { if case .segmentSkip? = model.lastSeekType { return false }} return !controls.presentingControls && !controls.presentingOverlays && model.presentingOSD } diff --git a/Shared/Player/Controls/TimelineView.swift b/Shared/Player/Controls/TimelineView.swift index 84508ccf..81be7876 100644 --- a/Shared/Player/Controls/TimelineView.swift +++ b/Shared/Player/Controls/TimelineView.swift @@ -52,6 +52,8 @@ struct TimelineView: View { @Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout @Default(.sponsorBlockColors) private var sponsorBlockColors + @Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved + @Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline var playerControlsLayout: PlayerControlsLayout { player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout @@ -84,13 +86,15 @@ struct TimelineView: View { Group { VStack(spacing: 3) { if dragging { - if let segment = projectedSegment, - let description = SponsorBlockAPI.categoryDescription(segment.category) - { - Text(description) - .font(.system(size: playerControlsLayout.segmentFontSize)) - .fixedSize() - .foregroundColor(getColor(for: segment.category)) + if showCategoriesInTimeline { + if let segment = projectedSegment, + let description = SponsorBlockAPI.categoryDescription(segment.category) + { + Text(description) + .font(.system(size: playerControlsLayout.segmentFontSize)) + .fixedSize() + .foregroundColor(getColor(for: segment.category)) + } } if let chapter = projectedChapter { Text(chapter.title) @@ -156,8 +160,10 @@ struct TimelineView: View { .frame(width: (dragging ? projectedValue : current) * oneUnitWidth) .zIndex(1) - segmentsLayers - .zIndex(2) + if showCategoriesInTimeline { + segmentsLayers + .zIndex(2) + } } .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) @@ -247,7 +253,7 @@ struct TimelineView: View { } } } else { - Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime) + Text(dragging || !showTimeWithSkipsRemoved ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime) .clipShape(RoundedRectangle(cornerRadius: 3)) .frame(minWidth: 35) } diff --git a/Shared/Settings/SponsorBlockSettings.swift b/Shared/Settings/SponsorBlockSettings.swift index f51a887a..e285d5e1 100644 --- a/Shared/Settings/SponsorBlockSettings.swift +++ b/Shared/Settings/SponsorBlockSettings.swift @@ -8,6 +8,9 @@ struct SponsorBlockSettings: View { @Default(.sponsorBlockInstance) private var sponsorBlockInstance @Default(.sponsorBlockCategories) private var sponsorBlockCategories @Default(.sponsorBlockColors) private var sponsorBlockColors + @Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved + @Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline + @Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip var body: some View { Group { @@ -42,6 +45,13 @@ struct SponsorBlockSettings: View { .keyboardType(.URL) #endif } + + Section(header: Text("Playback")) { + Toggle("Categories in timeline", isOn: $showCategoriesInTimeline) + Toggle("Post-skip notice", isOn: $showNoticeAfterSkip) + Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved) + } + Section(header: SettingsHeader(text: "Categories to Skip".localized())) { categoryRows }