-
-
Notifications
You must be signed in to change notification settings - Fork 0
YouTube-style autoplay with an up-next countdown card #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Void, Never>? | ||
| /// 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) | ||
| } | ||
|
Comment on lines
+2026
to
+2035
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The fallback Severity Level: Major
|
||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
Comment on lines
+2042
to
+2054
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The countdown task remains active when the user invokes pause or resume after the video has finished. Resuming the current video therefore does not dismiss the pending autoplay transition, and the task can still call Severity Level: Major
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| /// 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |
| /// The surface is the singleton `YouTubeWatchWebView`, docked here while | ||
| /// this view owns it. Navigating away while playing hands the surface off | ||
| /// to the floating window (`YouTubeVideoWindowController`). | ||
| struct YouTubeWatchView: View { | ||
| @MainActor fileprivate static var brandAccent: Color { SettingsManager.shared.accentColor } | ||
|
|
||
| let video: YouTubeVideo | ||
|
|
@@ -260,6 +260,12 @@ | |
| } | ||
| .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) { | ||
|
|
@@ -1202,12 +1208,12 @@ | |
| let separator = index == collaborators.count - 1 | ||
| ? " \(String(localized: "and")) " | ||
| : ", " | ||
| result = result + Text(separator).foregroundColor(.secondary) | ||
|
Check failure on line 1211 in Sources/Kaset/Views/YouTube/YouTubeWatchView.swift
|
||
| } | ||
| result = result + Text(collaborator.name).fontWeight(.semibold) | ||
| if collaborator.isVerified { | ||
| result = result + Text(" ") | ||
| + Text(Image(systemName: "checkmark.seal.fill")).foregroundColor(.secondary) | ||
| } | ||
| } | ||
| return result | ||
|
|
@@ -1265,8 +1271,29 @@ | |
| } | ||
|
|
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The autoplay switch is added to Severity Level: Major
|
||
| } | ||
| } | ||
|
|
||
| /// 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 } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Turning the toggle off only updates Severity Level: Major
|
||
| )) { | ||
| 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 { | ||
|
|
@@ -2358,4 +2385,4 @@ | |
| } | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The autoplay setting is checked only when creating a countdown. Turning the setting off afterward does not cancel the existing countdown task, and that task never rechecks the setting before confirming playback, so the queued video can still start even though autoplay is now disabled. Invalidate or clear the countdown when the setting changes, or check the current setting before confirming. [logic error]
Severity Level: Major⚠️
Steps of Reproduction ✅
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖