Merge pull request #639 from stonerl/sponsor-block

SponsorBlock Improvements
This commit is contained in:
Arkadiusz Fal 2024-05-16 18:15:38 +02:00 committed by GitHub
commit 9d291cca28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 203 additions and 62 deletions

View File

@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
func showOSD() { func showOSD() {
guard !presentingOSD else { return } guard !presentingOSD else { return }
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true } presentingOSD = true
} }
func hideOSD() { func hideOSD() {
guard presentingOSD else { return } guard presentingOSD else { return }
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false } presentingOSD = false
} }
func hideOSDWithDelay() { func hideOSDWithDelay() {

View File

@ -5,7 +5,7 @@ import Logging
import SwiftyJSON import SwiftyJSON
final class SponsorBlockAPI: ObservableObject { 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") let logger = Logger(label: "stream.yattee.app.sb")
@ -21,15 +21,19 @@ final class SponsorBlockAPI: ObservableObject {
case "sponsor": case "sponsor":
return "Sponsor".localized() return "Sponsor".localized()
case "selfpromo": case "selfpromo":
return "Self-promotion".localized() return "Unpaid/Self Promotion".localized()
case "intro":
return "Intro".localized()
case "outro":
return "Outro".localized()
case "interaction": 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": case "music_offtopic":
return "Offtopic in Music Videos".localized() return "Music: Non-Music Section".localized()
default: default:
return name.capitalized 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() "The creator will receive payment or compensation in the form of money or free products.").localized()
case "selfpromo": 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() "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": case "intro":
return ("Segments typically found at the start of a video that include an animation, " + 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() "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": case "outro":
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized() return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
case "interaction": case "preview":
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized() 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": case "music_offtopic":
return "For videos which feature music as the primary content.".localized() 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.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
self.logger.info("loaded \(self.segments.count) SponsorBlock segments") self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
self.segments.forEach { for segment in self.segments {
self.logger.info("\($0.start) -> \($0.end)") self.logger.info("\(segment.start) -> \(segment.end)")
} }
case let .failure(error): case let .failure(error):
self.segments = [] self.segments = []

View File

@ -243,6 +243,10 @@ extension Defaults.Keys {
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app") static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories)) static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
// MARK: GROUP - Locations // MARK: GROUP - Locations
@ -580,3 +584,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
case horizontalCells case horizontalCells
case list 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
}()
}

View File

@ -13,6 +13,18 @@ struct Seek: View {
@Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout @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 { var body: some View {
Group { Group {
@ -25,6 +37,7 @@ struct Seek: View {
#endif #endif
} }
.opacity(visible || YatteeApp.isForPreviews ? 1 : 0) .opacity(visible || YatteeApp.isForPreviews ? 1 : 0)
.animation(.easeIn)
} }
var content: some View { var content: some View {
@ -51,7 +64,8 @@ struct Seek: View {
if let segment = projectedSegment { if let segment = projectedSegment {
Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor") Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize)) .font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor")) .foregroundColor(getColor(for: segment.category))
.padding(.bottom, 3)
} }
} else { } else {
#if !os(tvOS) #if !os(tvOS)
@ -69,7 +83,8 @@ struct Seek: View {
Divider() Divider()
Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor") Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize)) .font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor")) .foregroundColor(getColor(for: category))
.padding(.bottom, 3)
default: default:
EmptyView() EmptyView()
} }
@ -117,6 +132,7 @@ struct Seek: View {
var visible: Bool { var visible: Bool {
guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false } guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false }
if let type = model.lastSeekType, !type.presentable { 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 return !controls.presentingControls && !controls.presentingOverlays && model.presentingOSD
} }

View File

@ -51,11 +51,24 @@ struct TimelineView: View {
@Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
@Default(.sponsorBlockColors) private var sponsorBlockColors
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
var playerControlsLayout: PlayerControlsLayout { var playerControlsLayout: PlayerControlsLayout {
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout 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] { var chapters: [Chapter] {
player.currentVideo?.chapters ?? [] player.currentVideo?.chapters ?? []
} }
@ -73,13 +86,15 @@ struct TimelineView: View {
Group { Group {
VStack(spacing: 3) { VStack(spacing: 3) {
if dragging { if dragging {
if let segment = projectedSegment, if showCategoriesInTimeline {
let description = SponsorBlockAPI.categoryDescription(segment.category) if let segment = projectedSegment,
{ let description = SponsorBlockAPI.categoryDescription(segment.category)
Text(description) {
.font(.system(size: playerControlsLayout.segmentFontSize)) Text(description)
.fixedSize() .font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor")) .fixedSize()
.foregroundColor(getColor(for: segment.category))
}
} }
if let chapter = projectedChapter { if let chapter = projectedChapter {
Text(chapter.title) Text(chapter.title)
@ -145,8 +160,10 @@ struct TimelineView: View {
.frame(width: (dragging ? projectedValue : current) * oneUnitWidth) .frame(width: (dragging ? projectedValue : current) * oneUnitWidth)
.zIndex(1) .zIndex(1)
segmentsLayers if showCategoriesInTimeline {
.zIndex(2) segmentsLayers
.zIndex(2)
}
} }
.clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
@ -236,7 +253,7 @@ struct TimelineView: View {
} }
} }
} else { } else {
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime) Text(dragging || !showTimeWithSkipsRemoved ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
.clipShape(RoundedRectangle(cornerRadius: 3)) .clipShape(RoundedRectangle(cornerRadius: 3))
.frame(minWidth: 35) .frame(minWidth: 35)
} }
@ -299,7 +316,7 @@ struct TimelineView: View {
ForEach(segments, id: \.uuid) { segment in ForEach(segments, id: \.uuid) { segment in
Rectangle() Rectangle()
.offset(x: segmentLayerHorizontalOffset(segment)) .offset(x: segmentLayerHorizontalOffset(segment))
.foregroundColor(Color("AppRedColor")) .foregroundColor(getColor(for: segment.category))
.frame(maxHeight: height) .frame(maxHeight: height)
.frame(width: segmentLayerWidth(segment)) .frame(width: segmentLayerWidth(segment))
} }

View File

@ -1,15 +1,21 @@
import Defaults import Defaults
import SwiftUI import SwiftUI
import UIKit
struct SponsorBlockSettings: View { struct SponsorBlockSettings: View {
@ObservedObject private var settings = SettingsModel.shared
@Default(.sponsorBlockInstance) private var sponsorBlockInstance @Default(.sponsorBlockInstance) private var sponsorBlockInstance
@Default(.sponsorBlockCategories) private var sponsorBlockCategories @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 { var body: some View {
Group { Group {
#if os(macOS) #if os(macOS)
sections sections
Spacer() Spacer()
#else #else
List { List {
@ -35,41 +41,70 @@ struct SponsorBlockSettings: View {
.labelsHidden() .labelsHidden()
#if !os(macOS) #if !os(macOS)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.URL) .keyboardType(.URL)
#endif #endif
} }
Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) { Section(header: Text("Playback")) {
#if os(macOS) Toggle("Categories in timeline", isOn: $showCategoriesInTimeline)
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in Toggle("Post-skip notice", isOn: $showNoticeAfterSkip)
MultiselectRow( Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved)
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown", }
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
Group { Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
if #available(macOS 12.0, *) { categoryRows
list }
.listStyle(.inset(alternatesRowBackgrounds: true)) colorSection
} else {
list Button {
.listStyle(.inset) settings.presentAlert(
} Alert(
} title: Text("Restore Default Colors?"),
Spacer() message: Text("This action will reset all custom colors back to their original defaults. " +
#else "Any custom color changes you've made will be lost."),
ForEach(SponsorBlockAPI.categories, id: \.self) { category in primaryButton: .destructive(Text("Restore")) {
MultiselectRow( resetColors()
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown", },
selected: sponsorBlockCategories.contains(category) secondaryButton: .cancel()
) { value in )
toggleCategory(category, value: value) )
} } label: {
} Text("Restore Default Colors …")
#endif .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 ForEach(SponsorBlockAPI.categories, id: \.self) { category in
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category") Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
.fontWeight(.bold) .fontWeight(.bold)
.padding(.bottom, 0.5)
#if os(tvOS) #if os(tvOS)
.focusable() .focusable()
#endif #endif
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details") Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
.padding(.bottom, 3) .padding(.bottom, 10)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, 3)
} }
func toggleCategory(_ category: String, value: Bool) { func toggleCategory(_ category: String, value: Bool) {
@ -99,6 +134,40 @@ struct SponsorBlockSettings: View {
sponsorBlockCategories.insert(category) 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 { struct SponsorBlockSettings_Previews: PreviewProvider {

View File

@ -108,7 +108,7 @@
"Enter fullscreen in landscape" = "Enter fullscreen in landscape"; "Enter fullscreen in landscape" = "Enter fullscreen in landscape";
"Error" = "Error"; "Error" = "Error";
"Error when accessing playlist" = "Error when accessing playlist"; "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"; "Favorites" = "Favorites";
"Filter" = "Filter"; "Filter" = "Filter";
"Filter: active" = "Filter: active"; "Filter: active" = "Filter: active";