mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
Add Sponsor Block and settings
This commit is contained in:
@@ -40,8 +40,4 @@ final class InstancesModel: ObservableObject {
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
}
|
||||
}
|
||||
|
||||
static func setLastAccount(_ account: Account?) {
|
||||
Defaults[.lastAccountID] = account?.id
|
||||
}
|
||||
}
|
||||
|
@@ -343,10 +343,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylists(from json: JSON) -> [ChannelPlaylist] {
|
||||
json.arrayValue.map(InvidiousAPI.extractChannelPlaylist)
|
||||
}
|
||||
|
||||
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.map { json in
|
||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||
|
@@ -185,10 +185,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylists(from json: JSON) -> [ChannelPlaylist] {
|
||||
json.arrayValue.compactMap(PipedAPI.extractChannelPlaylist)
|
||||
}
|
||||
|
||||
private static func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
let url = details["url"]?.string
|
||||
|
@@ -1,33 +0,0 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "outro", "intro", "music_offtopic", "interaction"]
|
||||
|
||||
var id: String
|
||||
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
init(_ id: String) {
|
||||
self.id = id
|
||||
}
|
||||
|
||||
func load() {
|
||||
AF.request("https://sponsor.ajay.app/api/skipSegments", parameters: parameters).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.segments = JSON(value).arrayValue.map { SponsorBlockSegment($0) }
|
||||
case let .failure(error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var parameters: [String: String] {
|
||||
[
|
||||
"videoID": id,
|
||||
"categories": JSON(SponsorBlockAPI.categories).rawString(String.Encoding.utf8)!
|
||||
]
|
||||
}
|
||||
}
|
@@ -6,6 +6,7 @@ import Logging
|
||||
import UIKit
|
||||
#endif
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerModel: ObservableObject {
|
||||
@@ -22,8 +23,8 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float?
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { rebuildStreamsMenu() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildStreamsMenu() } }
|
||||
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]()
|
||||
@Published var currentItem: PlayerQueueItem!
|
||||
@@ -33,6 +34,11 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published var playerNavigationLinkActive = false
|
||||
|
||||
@Published var sponsorBlock = SponsorBlockAPI()
|
||||
@Published var segmentRestorationTime: CMTime?
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
var accounts: AccountsModel
|
||||
var instances: InstancesModel
|
||||
|
||||
@@ -117,6 +123,9 @@ final class PlayerModel: ObservableObject {
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
resetSegments()
|
||||
sponsorBlock.loadSegments(videoID: video.videoID)
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
|
||||
@@ -218,7 +227,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
|
||||
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1_000_000)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
@@ -327,19 +336,28 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
player.seek(
|
||||
to: time,
|
||||
toleranceBefore: .init(seconds: 1, preferredTimescale: 1000),
|
||||
toleranceBefore: .init(seconds: 1, preferredTimescale: 1_000_000),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func addTimeObserver() {
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 1000)
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 1_000_000)
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in
|
||||
self.currentRate = self.player.rate
|
||||
self.currentItem?.playbackTime = self.player.currentTime()
|
||||
self.currentItem?.videoDuration = self.player.currentItem?.asset.duration.seconds
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = self.player.currentTime()
|
||||
|
||||
self.currentItem!.playbackTime = time
|
||||
self.currentItem!.videoDuration = self.player.currentItem?.asset.duration.seconds
|
||||
|
||||
self.handleSegments(at: time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
77
Model/Player/PlayerSegments.swift
Normal file
77
Model/Player/PlayerSegments.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
func handleSegments(at time: CMTime) {
|
||||
if let segment = lastSkipped {
|
||||
if time > CMTime(seconds: segment.end + 10, preferredTimescale: 1_000_000) {
|
||||
resetLastSegment()
|
||||
}
|
||||
}
|
||||
guard let firstSegment = sponsorBlock.segments.first(where: { $0.timeInSegment(time) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// find last segment in case they are 2 sec or less after each other
|
||||
// to avoid multiple skips in a row
|
||||
var nextSegments = [firstSegment]
|
||||
|
||||
while let segment = sponsorBlock.segments.first(where: {
|
||||
$0.timeInSegment(CMTime(seconds: nextSegments.last!.end + 2, preferredTimescale: 1_000_000))
|
||||
}) {
|
||||
nextSegments.append(segment)
|
||||
}
|
||||
|
||||
if let segmentToSkip = nextSegments.last(where: { $0.endTime <= playerItemDuration ?? .zero }),
|
||||
self.shouldSkip(segmentToSkip, at: time)
|
||||
{
|
||||
skip(segmentToSkip, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
guard segment.endTime.seconds <= playerItemDuration?.seconds ?? .infinity else {
|
||||
logger.error("item time is: \(time.seconds) and trying to skip to \(playerItemDuration?.seconds ?? .infinity)")
|
||||
return
|
||||
}
|
||||
|
||||
player.seek(to: segment.endTime)
|
||||
lastSkipped = segment
|
||||
segmentRestorationTime = time
|
||||
logger.info("SponsorBlock skipping to: \(segment.endTime)")
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
guard isPlaying,
|
||||
!restoredSegments.contains(segment),
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.seconds - segment.start < 2 && segment.end - time.seconds > 2
|
||||
}
|
||||
|
||||
func restoreLastSkippedSegment() {
|
||||
guard let segment = lastSkipped,
|
||||
let time = segmentRestorationTime
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
restoredSegments.append(segment)
|
||||
player.seek(to: time)
|
||||
resetLastSegment()
|
||||
}
|
||||
|
||||
private func resetLastSegment() {
|
||||
lastSkipped = nil
|
||||
segmentRestorationTime = nil
|
||||
}
|
||||
|
||||
func resetSegments() {
|
||||
resetLastSegment()
|
||||
restoredSegments = []
|
||||
}
|
||||
}
|
@@ -57,47 +57,13 @@ extension PlayerModel {
|
||||
completionHandler: @escaping ([Stream]) -> Void
|
||||
) {
|
||||
instancesWithLoadedStreams.append(instance)
|
||||
rebuildStreamsMenu()
|
||||
rebuildTVMenu()
|
||||
|
||||
if instances.all.count == instancesWithLoadedStreams.count {
|
||||
completionHandler(streams.sorted { $0.kind < $1.kind })
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
var streamsMenu: UIMenu {
|
||||
UIMenu(
|
||||
title: "Streams",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right"),
|
||||
children: streamsMenuActions
|
||||
)
|
||||
}
|
||||
|
||||
var streamsMenuActions: [UIAction] {
|
||||
guard !availableStreams.isEmpty else {
|
||||
return [ // swiftlint:disable:this implicit_return
|
||||
UIAction(title: "Empty", attributes: .disabled) { _ in }
|
||||
]
|
||||
}
|
||||
|
||||
return availableStreamsSorted.map { stream in
|
||||
let state = stream == streamSelection ? UIAction.State.on : .off
|
||||
|
||||
return UIAction(title: stream.description, state: state) { _ in
|
||||
self.streamSelection = stream
|
||||
self.upgradeToStream(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
func rebuildStreamsMenu() {
|
||||
#if os(tvOS)
|
||||
avPlayerViewController?.transportBarCustomMenuItems = [streamsMenu]
|
||||
#endif
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
|
55
Model/Player/PlayerTVMenu.swift
Normal file
55
Model/Player/PlayerTVMenu.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension PlayerModel {
|
||||
#if os(tvOS)
|
||||
var streamsMenu: UIMenu {
|
||||
UIMenu(
|
||||
title: "Streams",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right"),
|
||||
children: streamsMenuActions
|
||||
)
|
||||
}
|
||||
|
||||
var streamsMenuActions: [UIAction] {
|
||||
guard !availableStreams.isEmpty else {
|
||||
return [ // swiftlint:disable:this implicit_return
|
||||
UIAction(title: "Empty", attributes: .disabled) { _ in }
|
||||
]
|
||||
}
|
||||
|
||||
return availableStreamsSorted.map { stream in
|
||||
let state = stream == streamSelection ? UIAction.State.on : .off
|
||||
|
||||
return UIAction(title: stream.description, state: state) { _ in
|
||||
self.streamSelection = stream
|
||||
self.upgradeToStream(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var restoreLastSkippedSegmentAction: UIAction? {
|
||||
guard let segment = lastSkipped else {
|
||||
return nil // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
return UIAction(
|
||||
title: "Restore \(segment.category)",
|
||||
image: UIImage(systemName: "arrow.uturn.left.circle")
|
||||
) { _ in
|
||||
self.restoreLastSkippedSegment()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func rebuildTVMenu() {
|
||||
#if os(tvOS)
|
||||
avPlayerViewController?.transportBarCustomMenuItems = [
|
||||
restoreLastSkippedSegmentAction,
|
||||
streamsMenu
|
||||
].compactMap { $0 }
|
||||
#endif
|
||||
}
|
||||
}
|
@@ -13,10 +13,18 @@ class Segment: ObservableObject, Hashable {
|
||||
segment.first!
|
||||
}
|
||||
|
||||
var startTime: CMTime {
|
||||
CMTime(seconds: start, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
var end: Double {
|
||||
segment.last!
|
||||
}
|
||||
|
||||
var endTime: CMTime {
|
||||
CMTime(seconds: end, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
var duration: Double {
|
||||
end - start
|
||||
}
|
||||
@@ -32,10 +40,6 @@ class Segment: ObservableObject, Hashable {
|
||||
(start ... end).contains(time.seconds)
|
||||
}
|
||||
|
||||
var skipTo: CMTime {
|
||||
CMTime(seconds: segment.last!, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(uuid)
|
||||
}
|
||||
@@ -47,8 +51,4 @@ class Segment: ObservableObject, Hashable {
|
||||
func title() -> String {
|
||||
category
|
||||
}
|
||||
|
||||
func shouldSkip(_ atTime: CMTime) -> Bool {
|
||||
atTime.seconds - start < 2 && end - atTime.seconds > 2
|
||||
}
|
||||
}
|
||||
|
73
Model/SponsorBlock/SponsorBlockAPI.swift
Normal file
73
Model/SponsorBlock/SponsorBlockAPI.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Alamofire
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
let logger = Logger(label: "net.yattee.app.sb")
|
||||
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
static func categoryDescription(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "selfpromo":
|
||||
return "Self-promotion"
|
||||
case "music_offtopic":
|
||||
return "Offtopic in Music Videos"
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func loadSegments(videoID: String) {
|
||||
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
requestSegments()
|
||||
}
|
||||
|
||||
private func requestSegments() {
|
||||
guard let url = skipSegmentsURL else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(url, parameters: parameters).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
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)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
||||
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var skipSegmentsURL: String? {
|
||||
let url = Defaults[.sponsorBlockInstance]
|
||||
return url.isEmpty ? nil : "\(url)/api/skipSegments"
|
||||
}
|
||||
|
||||
private var parameters: [String: String] {
|
||||
[
|
||||
"videoID": videoID!,
|
||||
"categories": JSON(SponsorBlockAPI.categories).rawString(String.Encoding.utf8)!
|
||||
]
|
||||
}
|
||||
}
|
@@ -16,7 +16,7 @@ final class SponsorBlockSegment: Segment {
|
||||
case "selfpromo":
|
||||
return "self-promotion"
|
||||
case "music_offtopic":
|
||||
return "to music"
|
||||
return "offtopic"
|
||||
default:
|
||||
return category
|
||||
}
|
@@ -109,10 +109,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.encoding = encoding
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
kind == .hls ? "adaptive" : resolution.name
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
Reference in New Issue
Block a user