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() {
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() {

View File

@ -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 = []

View File

@ -243,6 +243,10 @@ extension Defaults.Keys {
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
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
@ -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
}()
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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";