diff --git a/Sources/Kaset/KasetApp.swift b/Sources/Kaset/KasetApp.swift index 9d336af7..825bce25 100644 --- a/Sources/Kaset/KasetApp.swift +++ b/Sources/Kaset/KasetApp.swift @@ -322,6 +322,13 @@ struct KasetApp: App { arbiter: self.playbackArbiter ) } + .onChange(of: NetworkMonitor.shared.isConnected) { _, isConnected in + // Auto-retry (issue #19): when connectivity returns, revive a + // video that stalled during the outage instead of leaving it stuck. + if isConnected { + self.youtubePlayerService.handleNetworkRestored() + } + } .onChange(of: self.playerService.isMiniPlayerVisible) { _, isVisible in self.handleMiniPlayerVisibilityChange(isVisible) } @@ -449,10 +456,17 @@ struct KasetApp: App { } .keyboardShortcut("s", modifiers: .command) - // Repeat - ⌘R + // Repeat - ⌘⇧R (⌘R is Refresh, below) Button(self.repeatModeLabel) { self.playerService.cycleRepeatMode() } + .keyboardShortcut("r", modifiers: [.command, .shift]) + + // Refresh video playback - ⌘R. Recovers a stuck/black player + // after a network interruption, reloading at the same position. + Button(String(localized: "Refresh")) { + self.youtubePlayerService.refreshCurrentVideo() + } .keyboardShortcut("r", modifiers: .command) Divider() diff --git a/Sources/Kaset/Services/Player/YouTubePlayerService.swift b/Sources/Kaset/Services/Player/YouTubePlayerService.swift index 931e86e6..f61d6ba2 100644 --- a/Sources/Kaset/Services/Player/YouTubePlayerService.swift +++ b/Sources/Kaset/Services/Player/YouTubePlayerService.swift @@ -605,6 +605,42 @@ final class YouTubePlayerService { self.isPlaybackLoading = true } + // MARK: - Refresh (⌘R) & network auto-retry + + /// User-triggered refresh (⌘R): reloads the current video's playback surface + /// at its last position, to recover a stuck/black player after a network + /// interruption without losing the user's place. No-op when nothing is loaded. + /// + /// ponytail: a reload autoplays the watch page, so hitting this on a + /// deliberately-paused video resumes it — acceptable for a "refresh" action, + /// and the auto-retry path below only calls it when playback should be running. + func refreshCurrentVideo() { + guard let currentVideo = self.currentVideo else { return } + self.logger.info("Manual refresh: reloading current video") + self.beginYouTubePlaybackIntent() + let resumeAt = self.interruptionResumeAt(for: currentVideo.videoId) + self.playbackController.prepare( + webKitManager: self.webKitManager, + playerService: self, + usesCookieFreeDataStore: self.usesCookieFreePlaybackDataStore + ) + self.playbackController.reloadVideo(videoId: currentVideo.videoId, resumeAt: resumeAt) + self.isPlaybackLoading = true + } + + /// Auto-retry when connectivity returns (issue #19): if a video was meant to + /// be playing but stalled during a network drop — still "loading", or not + /// playing while the user intends to — reload it. Deliberately-paused + /// playback is left alone so a network blip never yanks it back to life. + func handleNetworkRestored() { + guard self.currentVideo != nil else { return } + let stalled = self.isPlaybackLoading + || (self.desiredPlaybackIntent == .playing && !self.isPlaying) + guard stalled else { return } + self.logger.info("Network restored: auto-reloading stalled video") + self.refreshCurrentVideo() + } + /// Returns the best native resume target for an interrupted document. /// Native seek intent wins until the observer proves it was applied; /// otherwise use the last genuine content clock. diff --git a/Tests/KasetTests/YouTubePlayerServiceTests.swift b/Tests/KasetTests/YouTubePlayerServiceTests.swift index f45e933c..670aa04c 100644 --- a/Tests/KasetTests/YouTubePlayerServiceTests.swift +++ b/Tests/KasetTests/YouTubePlayerServiceTests.swift @@ -218,6 +218,54 @@ struct YouTubePlayerServiceTests { #expect(self.controller.reloadedVideoIds.isEmpty) } + @Test("Manual refresh (⌘R) reloads the current video") + func refreshReloadsCurrentVideo() { + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc")) + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 12, duration: 60, videoId: "abc")) + + self.sut.refreshCurrentVideo() + + // A forced reload of the same id (not a no-op loadVideo). The resume + // target is the existing interruption-resume machinery, covered above. + #expect(self.controller.reloadedVideoIds == ["abc"]) + } + + @Test("Manual refresh is a no-op when nothing is playing") + func refreshNoOpWhenIdle() { + self.sut.refreshCurrentVideo() + #expect(self.controller.reloadedVideoIds.isEmpty) + } + + @Test("Network restored reloads a stalled (still-loading) video") + func networkRestoredReloadsStalledVideo() { + // play() leaves the surface loading (no playback update yet) — the + // stuck-after-network-drop state. + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc")) + #expect(self.sut.isPlaybackLoading) + + self.sut.handleNetworkRestored() + + #expect(self.controller.reloadedVideoIds == ["abc"]) + } + + @Test("Network restored leaves a normally-playing video alone") + func networkRestoredIgnoresHealthyPlayback() { + self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc")) + // A playback update clears the loading state and marks it playing. + self.sut.updatePlaybackState(.init(isPlaying: true, progress: 5, duration: 60, videoId: "abc")) + #expect(!self.sut.isPlaybackLoading) + + self.sut.handleNetworkRestored() + + #expect(self.controller.reloadedVideoIds.isEmpty) + } + + @Test("Network restored is a no-op when nothing is playing") + func networkRestoredNoOpWhenIdle() { + self.sut.handleNetworkRestored() + #expect(self.controller.reloadedVideoIds.isEmpty) + } + @Test("Identity-switch during an ad resumes the content, not the ad position") func reloadDuringAdUsesContentProgress() { self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc"))