From 6bb490df1a72d6ff7f3cb658fbb77661737ecdb6 Mon Sep 17 00:00:00 2001 From: "Alessio I." Date: Tue, 28 Jul 2026 13:57:14 +0200 Subject: [PATCH] Add YouTube-style autoplay with an up-next countdown card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new "Autoplay" toggle (default off) in the related header. When on, a finished video no longer stops: it queues the next suggested video behind a YouTube-style countdown card over the surface — "Up Next" + the next title, a 5s ring you can click to play now, or a Cancel button. On timeout or click it advances and starts playing automatically. Mixes/playlists are unaffected (they advance through their own queue via page drift). - SettingsManager.youtubeAutoplayEnabled (default off, persisted) - YouTubePlayerService: on a natural finish, start a 5s autoplay countdown to the first up-next (or a freshly fetched related video); confirm/cancel; the countdown is cleared by play/stop/advance so explicit playback overrides it - YouTubeAutoplayCountdownOverlay: reusable card hosted by both the inline watch view and the floating window - Toggle in the related header, matching YouTube's up-next autoplay switch - "Play now" localized (it: "Riproduci ora"); reuses "Up Next"/"Cancel" - Tests: off queues nothing, on queues a countdown (no instant switch), confirm advances now, cancel clears Co-Authored-By: Claude Opus 4.8 --- Sources/Kaset/Resources/Localizable.xcstrings | 22 +++++ .../Resources/it.lproj/Localizable.strings | 2 + .../Player/YouTubePlayerService.swift | 84 +++++++++++++++++++ Sources/Kaset/Services/SettingsManager.swift | 11 +++ .../YouTubeAutoplayCountdownOverlay.swift | 79 +++++++++++++++++ .../YouTubeVideoWindowController.swift | 4 + .../Views/YouTube/YouTubeWatchView.swift | 31 ++++++- .../YouTubePlayerServiceTests.swift | 76 +++++++++++++++++ 8 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 Sources/Kaset/Views/YouTube/YouTubeAutoplayCountdownOverlay.swift diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index c6b81849..d807111b 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -11669,6 +11669,17 @@ } } }, + "Autoplay" : { + "comment" : "Toggle: automatically play the next suggested video (it: 'Riproduzione automatica').", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riproduzione automatica" + } + } + } + }, "Available" : { "comment" : "Reusable label shown in the Foundation Models screen, Intelligence settings.", "localizations" : { @@ -73139,6 +73150,17 @@ } } }, + "Play now" : { + "comment" : "Tooltip on the autoplay countdown play button (it: 'Riproduci ora').", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riproduci ora" + } + } + } + }, "Play video" : { "comment" : "YouTube video watch page (below the player: title, channel, subscribe/bell, description, chapters, comments, related).", "localizations" : { diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index 6612d892..3d912da2 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -62,6 +62,7 @@ "Auto" = "Automatico"; "Auto-skips sponsored segments in videos." = "Auto-skips sponsored segments in videos."; "Automatically check for updates" = "Controlla automaticamente gli aggiornamenti"; +"Autoplay" = "Riproduzione automatica"; "Available" = "Disponibile"; "Back 30 seconds" = "Indietro di 30 secondi"; "Background Playback" = "Riproduzione in background"; @@ -395,6 +396,7 @@ "Play Next" = "Riproduci dopo"; "Play a song to view its lyrics here." = "Riproduci un brano per vederne qui il testo."; "Play songs from a playlist or album to build your queue." = "Riproduci brani da una playlist o da un album per creare la tua coda."; +"Play now" = "Riproduci ora"; "Play video" = "Riproduci video"; "Playback" = "Riproduzione"; "Playback Audio Quality" = "Qualità audio di riproduzione"; diff --git a/Sources/Kaset/Services/Player/YouTubePlayerService.swift b/Sources/Kaset/Services/Player/YouTubePlayerService.swift index 1bdce4f2..dee4b87b 100644 --- a/Sources/Kaset/Services/Player/YouTubePlayerService.swift +++ b/Sources/Kaset/Services/Player/YouTubePlayerService.swift @@ -416,6 +416,16 @@ final class YouTubePlayerService { /// Up-next candidates for skip-forward (related videos from the watch page). private(set) var upNext: [YouTubeVideo] = [] + /// The video queued to autoplay next, shown behind a countdown card after the + /// current video finishes (YouTube-style). Nil when no autoplay is pending. + private(set) var autoplayPendingVideo: YouTubeVideo? + /// Whole seconds left on the autoplay countdown (counts `autoplayCountdownSeconds`→0); + /// meaningful only while `autoplayPendingVideo` is set. + private(set) var autoplayCountdownRemaining = 0 + @ObservationIgnored private var autoplayCountdownTask: Task? + /// How long the "up next" countdown card lingers before advancing. + static let autoplayCountdownSeconds = 5 + /// Navigation chapters for the current video, loaded from the watch page's /// companion `next` response. private(set) var chapters: [YouTubeChapter] = [] @@ -516,6 +526,7 @@ final class YouTubePlayerService { /// Starts playback of a video, docked inline. func play(video: YouTubeVideo, usesCookieFreeDataStore: Bool = false, startAt: Double? = nil) { self.beginYouTubePlaybackIntent() + self.clearAutoplayCountdown() self.autoplayRecoveryRequestGeneration &+= 1 self.shouldRecoverAutoplayTransitionOnResume = false let normalizedStartAt = Self.normalizedExplicitStartAt(startAt) @@ -935,6 +946,7 @@ final class YouTubePlayerService { /// Stops playback entirely and releases the surface. func stop() { self.beginYouTubePlaybackIntent() + self.clearAutoplayCountdown() self.autoplayRecoveryRequestGeneration &+= 1 self.shouldRecoverAutoplayTransitionOnResume = false self.logger.info("YouTubePlayer: stop") @@ -1076,6 +1088,7 @@ final class YouTubePlayerService { } private func advance(to video: YouTubeVideo, recordingHistory: Bool = true) { + self.clearAutoplayCountdown() self.autoplayRecoveryRequestGeneration &+= 1 self.shouldRecoverAutoplayTransitionOnResume = false self.logger.info("YouTubePlayer: advancing to another video") @@ -1990,9 +2003,80 @@ extension YouTubePlayerService { self.watchActivityGeneration += 1 self.onVideoEnded?(videoId) } + self.autoplayNextIfEnabled() return true } + /// YouTube-style autoplay: when the setting is on, a finished video queues the + /// next suggested video (the first up-next candidate, or a freshly fetched + /// related video when none are known — e.g. a floating-window finish) behind a + /// countdown card, rather than switching immediately. Off by default, so + /// playback otherwise stops at the end. Mixes and playlists advance through + /// their own queue via page drift and are unaffected by this. + private func autoplayNextIfEnabled() { + guard SettingsManager.shared.youtubeAutoplayEnabled, + let current = self.currentVideo + else { return } + + if let next = self.upNext.first { + self.startAutoplayCountdown(to: next) + return + } + let expectedVideoId = current.videoId + Task { @MainActor in + guard let client = self.youtubeClient, + let next = try? await client.getWatchNext(videoId: expectedVideoId, playlistId: nil) + .related.first(where: { !$0.isShort }), + SettingsManager.shared.youtubeAutoplayEnabled, + self.currentVideo?.videoId == expectedVideoId, + self.autoplayPendingVideo == nil + else { return } + self.startAutoplayCountdown(to: next) + } + } + + private func startAutoplayCountdown(to next: YouTubeVideo) { + self.autoplayCountdownTask?.cancel() + self.autoplayPendingVideo = next + self.autoplayCountdownRemaining = Self.autoplayCountdownSeconds + self.autoplayCountdownTask = Task { [weak self] in + while true { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + guard let self, self.autoplayPendingVideo?.videoId == next.videoId else { return } + self.autoplayCountdownRemaining -= 1 + if self.autoplayCountdownRemaining <= 0 { + self.confirmAutoplayNow() + return + } + } + } + } + + /// Plays the queued autoplay video right away (the countdown card's play + /// button — "speed it up"). + func confirmAutoplayNow() { + guard let next = self.autoplayPendingVideo else { return } + self.clearAutoplayCountdown() + self.advance(to: next) + } + + /// Dismisses the autoplay countdown without advancing (the card's cancel + /// button, or any explicit playback the user starts instead). + func cancelAutoplay() { + self.clearAutoplayCountdown() + } + + private func clearAutoplayCountdown() { + self.autoplayCountdownTask?.cancel() + self.autoplayCountdownTask = nil + self.autoplayPendingVideo = nil + self.autoplayCountdownRemaining = 0 + } + nonisolated static func isEndEventStale( eventIssuedAtMilliseconds: Double, lastResumeIssuedAtMilliseconds: Double diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift index 5ae461db..46fcbb8c 100644 --- a/Sources/Kaset/Services/SettingsManager.swift +++ b/Sources/Kaset/Services/SettingsManager.swift @@ -44,6 +44,7 @@ final class SettingsManager { static let dearrowEnabled = "settings.dearrow.enabled" static let hasCompletedInitialOnboarding = "settings.onboarding.completed" static let distractionFreeEnabled = "settings.distractionFree.enabled" + static let youtubeAutoplayEnabled = "settings.youtube.autoplay.enabled" static let accentColorHex = "settings.accentColorHex" #if DEBUG static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI" @@ -569,6 +570,15 @@ final class SettingsManager { } } + /// YouTube-style autoplay: when a video finishes, automatically play the next + /// suggested (up-next) video. Off by default; the fork otherwise stops at the + /// end (YouTube's own autonav is always disabled). + var youtubeAutoplayEnabled: Bool { + didSet { + UserDefaults.standard.set(self.youtubeAutoplayEnabled, forKey: Keys.youtubeAutoplayEnabled) + } + } + /// Whether the one-time first-launch onboarding (the addons walkthrough) /// has been completed. The full onboarding — changelog → Continue → Addons → /// Done — is shown only once, on first launch. Later versions surface just the @@ -665,6 +675,7 @@ final class SettingsManager { self.dearrowEnabled = UserDefaults.standard.object(forKey: Keys.dearrowEnabled) as? Bool ?? false self.hasCompletedInitialOnboarding = UserDefaults.standard.object(forKey: Keys.hasCompletedInitialOnboarding) as? Bool ?? false self.distractionFreeEnabled = UserDefaults.standard.object(forKey: Keys.distractionFreeEnabled) as? Bool ?? false + self.youtubeAutoplayEnabled = UserDefaults.standard.object(forKey: Keys.youtubeAutoplayEnabled) as? Bool ?? false #if DEBUG self.useLegacyMacOS15UI = UserDefaults.standard.object(forKey: Keys.useLegacyMacOS15UI) as? Bool ?? false #endif diff --git a/Sources/Kaset/Views/YouTube/YouTubeAutoplayCountdownOverlay.swift b/Sources/Kaset/Views/YouTube/YouTubeAutoplayCountdownOverlay.swift new file mode 100644 index 00000000..8dcf7e88 --- /dev/null +++ b/Sources/Kaset/Views/YouTube/YouTubeAutoplayCountdownOverlay.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - YouTubeAutoplayCountdownOverlay + +/// YouTube-style "up next" countdown card, shown over the video surface after a +/// video finishes when autoplay is on: it names the next video and counts down +/// inside a ring the user can click to play now ("speed it up"), or cancel. +/// +/// Reads the shared `YouTubePlayerService`, so both the inline watch view and the +/// floating window can host it; it renders nothing when no autoplay is pending. +struct YouTubeAutoplayCountdownOverlay: View { + @Environment(YouTubePlayerService.self) private var youtubePlayer + + var body: some View { + if let next = self.youtubePlayer.autoplayPendingVideo { + self.card(next: next) + .transition(.opacity) + } + } + + private func card(next: YouTubeVideo) -> some View { + let total = YouTubePlayerService.autoplayCountdownSeconds + let remaining = max(self.youtubePlayer.autoplayCountdownRemaining, 0) + let fraction = CGFloat(remaining) / CGFloat(max(total, 1)) + + return ZStack { + Rectangle().fill(.black.opacity(0.62)) + + VStack(spacing: 14) { + Text("Up Next", comment: "Label above the autoplay countdown card") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.white.opacity(0.7)) + + Text(next.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(2) + + // The ring counts down; clicking it plays the next video now. + Button { + self.youtubePlayer.confirmAutoplayNow() + } label: { + ZStack { + Circle().stroke(.white.opacity(0.25), lineWidth: 3) + Circle() + .trim(from: 0, to: fraction) + .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text("\(remaining)") + .font(.system(size: 22, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + } + .frame(width: 62, height: 62) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(Text("Play now", comment: "Tooltip on the autoplay countdown play button")) + + Button { + self.youtubePlayer.cancelAutoplay() + } label: { + Text("Cancel", comment: "Button to cancel autoplay to the next video") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .frame(height: 30) + .compatGlass(interactive: true, tint: nil, in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(24) + .frame(maxWidth: 440) + } + .animation(.linear(duration: 1), value: remaining) + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift index 3bd7ba1e..7766bf4e 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift @@ -383,6 +383,10 @@ private struct YouTubeVideoWindowContent: View { ZStack(alignment: .bottom) { YouTubeWatchSurfaceView() .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + YouTubeAutoplayCountdownOverlay() + } + .animation(.easeInOut(duration: 0.25), value: self.youtubePlayer.autoplayPendingVideo == nil) if self.isHovering { // The full player bar — same items as the main window. diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 6840e0e5..ed3df620 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -260,6 +260,12 @@ struct YouTubeWatchView: View { } .animation(.easeInOut(duration: 0.25), value: self.youtubePlayer.isPlaybackLoading) .animation(.easeInOut(duration: 0.25), value: self.viewModel.membersGate == nil) + // YouTube-style "up next" countdown card when a finished video is + // about to autoplay the next one. + .overlay { + YouTubeAutoplayCountdownOverlay() + } + .animation(.easeInOut(duration: 0.25), value: self.youtubePlayer.autoplayPendingVideo == nil) // "Ad" badge: a server-side (SSAI) ad can still slip past the // blocker; label it so it's clear this isn't the content. .overlay(alignment: .topLeading) { @@ -1265,8 +1271,29 @@ struct YouTubeWatchView: View { } private var relatedHeader: some View { - Text("Related", comment: "Related videos section header") - .font(.title3.bold()) + HStack(alignment: .firstTextBaseline) { + Text("Related", comment: "Related videos section header") + .font(.title3.bold()) + Spacer(minLength: 12) + self.autoplayToggle + } + } + + /// YouTube-style "Autoplay" switch: when on, a finished video advances to the + /// next suggested video. Off by default. Lives in the related header, like + /// YouTube's up-next autoplay toggle. + private var autoplayToggle: some View { + Toggle(isOn: Binding( + get: { self.settings.youtubeAutoplayEnabled }, + set: { self.settings.youtubeAutoplayEnabled = $0 } + )) { + Text("Autoplay", comment: "Toggle: automatically play the next suggested video") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + .toggleStyle(.switch) + .controlSize(.mini) + .fixedSize() } private var relatedSkeleton: some View { diff --git a/Tests/KasetTests/YouTubePlayerServiceTests.swift b/Tests/KasetTests/YouTubePlayerServiceTests.swift index eadac28c..05c12b32 100644 --- a/Tests/KasetTests/YouTubePlayerServiceTests.swift +++ b/Tests/KasetTests/YouTubePlayerServiceTests.swift @@ -794,6 +794,82 @@ struct YouTubePlayerServiceTests { #expect(didAcceptEnd) } + @Test("Autoplay off: a finished video queues nothing and does not advance") + func autoplayOffDoesNotQueueNext() { + let settings = SettingsManager.shared + let original = settings.youtubeAutoplayEnabled + defer { settings.youtubeAutoplayEnabled = original } + settings.youtubeAutoplayEnabled = false + + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "a")) + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 9, duration: 10, videoId: "a")) + self.sut.setUpNext([MockYouTubeClient.makeVideo(videoId: "b")]) + + _ = self.sut.handleVideoEnded(videoId: "a") + + #expect(self.sut.autoplayPendingVideo == nil) + #expect(self.sut.currentVideo?.videoId == "a") + #expect(!self.controller.loadedVideoIds.contains("b")) + } + + @Test("Autoplay on: a finished video queues the next behind a countdown, not an instant switch") + func autoplayOnQueuesCountdown() { + let settings = SettingsManager.shared + let original = settings.youtubeAutoplayEnabled + defer { settings.youtubeAutoplayEnabled = original } + settings.youtubeAutoplayEnabled = true + + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "a")) + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 9, duration: 10, videoId: "a")) + self.sut.setUpNext([MockYouTubeClient.makeVideo(videoId: "b")]) + + _ = self.sut.handleVideoEnded(videoId: "a") + + // Queued, not switched: the card counts down before advancing. + #expect(self.sut.autoplayPendingVideo?.videoId == "b") + #expect(self.sut.autoplayCountdownRemaining == YouTubePlayerService.autoplayCountdownSeconds) + #expect(self.sut.currentVideo?.videoId == "a") + #expect(!self.controller.loadedVideoIds.contains("b")) + } + + @Test("Confirming the countdown plays the next video immediately") + func autoplayConfirmAdvancesNow() { + let settings = SettingsManager.shared + let original = settings.youtubeAutoplayEnabled + defer { settings.youtubeAutoplayEnabled = original } + settings.youtubeAutoplayEnabled = true + + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "a")) + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 9, duration: 10, videoId: "a")) + self.sut.setUpNext([MockYouTubeClient.makeVideo(videoId: "b")]) + _ = self.sut.handleVideoEnded(videoId: "a") + + self.sut.confirmAutoplayNow() + + #expect(self.sut.currentVideo?.videoId == "b") + #expect(self.controller.loadedVideoIds.contains("b")) + #expect(self.sut.autoplayPendingVideo == nil) + } + + @Test("Cancelling the countdown clears it without advancing") + func autoplayCancelClears() { + let settings = SettingsManager.shared + let original = settings.youtubeAutoplayEnabled + defer { settings.youtubeAutoplayEnabled = original } + settings.youtubeAutoplayEnabled = true + + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "a")) + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 9, duration: 10, videoId: "a")) + self.sut.setUpNext([MockYouTubeClient.makeVideo(videoId: "b")]) + _ = self.sut.handleVideoEnded(videoId: "a") + + self.sut.cancelAutoplay() + + #expect(self.sut.autoplayPendingVideo == nil) + #expect(self.sut.currentVideo?.videoId == "a") + #expect(!self.controller.loadedVideoIds.contains("b")) + } + @Test("Duplicate ended callbacks run one-shot effects once and later autoplay can end") func duplicateEndedCallbacksAreIdempotentPerWatch() { self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "a"))