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/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/Defaults.swift b/Shared/Defaults.swift index 7df4c47d..4e8499e8 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -243,6 +243,10 @@ 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 @@ -580,3 +584,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..f729a9a0 100644 --- a/Shared/Player/Controls/OSD/Seek.swift +++ b/Shared/Player/Controls/OSD/Seek.swift @@ -13,6 +13,18 @@ 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) { + 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 { @@ -25,6 +37,7 @@ struct Seek: View { #endif } .opacity(visible || YatteeApp.isForPreviews ? 1 : 0) + .animation(.easeIn) } var content: some View { @@ -51,7 +64,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 +83,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() } @@ -117,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 7f39f4c5..81be7876 100644 --- a/Shared/Player/Controls/TimelineView.swift +++ b/Shared/Player/Controls/TimelineView.swift @@ -51,11 +51,24 @@ 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 } + 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 ?? [] } @@ -73,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(Color("AppRedColor")) + 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) @@ -145,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)) @@ -236,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) } @@ -299,7 +316,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 114a0154..e285d5e1 100644 --- a/Shared/Settings/SponsorBlockSettings.swift +++ b/Shared/Settings/SponsorBlockSettings.swift @@ -1,15 +1,21 @@ 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 + @Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved + @Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline + @Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip var body: some View { Group { #if os(macOS) sections - Spacer() #else List { @@ -35,41 +41,70 @@ struct SponsorBlockSettings: View { .labelsHidden() #if !os(macOS) .autocapitalization(.none) + .disableAutocorrection(true) .keyboardType(.URL) #endif } - 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) - } - } + Section(header: Text("Playback")) { + Toggle("Categories in timeline", isOn: $showCategoriesInTimeline) + Toggle("Post-skip notice", isOn: $showNoticeAfterSkip) + Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved) + } - 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(header: SettingsHeader(text: "Categories to Skip".localized())) { + categoryRows + } + colorSection + + 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) + } + + 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) + } } } } @@ -79,17 +114,17 @@ 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) } func toggleCategory(_ category: String, value: Bool) { @@ -99,6 +134,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 { 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";