Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Modules/Sources/PocketCastsDataModel/Public/Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ public enum PlayerAction: String, Codable, Equatable {
case transcript = "transcript"
case download = "download"
case addToPlaylist = "playlist"
case videoToggle = "video"
}

extension Array: @retroactive RawRepresentable where Element: RawRepresentable<String> {
Expand Down
14 changes: 14 additions & 0 deletions Modules/Sources/PocketCastsUtils/General/AtomicBool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public class AtomicBool {

public init() {}

public init(_ initialValue: Bool) {
storageValue = initialValue
}

Comment thread
leandroalonso marked this conversation as resolved.
public var value: Bool {
get {
atomicQueue.sync {
Expand All @@ -23,4 +27,14 @@ public class AtomicBool {
}
}
}

/// Atomically flips the value and returns the new value. Use this instead of
/// `value.toggle()`, which performs a separate read and write and can race.
@discardableResult
public func toggle() -> Bool {
atomicQueue.sync {
storageValue.toggle()
return storageValue
}
}
}
3 changes: 2 additions & 1 deletion PocketCastsTests/Tests/Utilities/SettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ final class SettingsTests: XCTestCase {
.goToPodcast,
.starEpisode,
.chromecast,
.archive
.archive,
.videoToggle
]
return actions
}()
Expand Down
3 changes: 3 additions & 0 deletions podcasts/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ struct Constants {
static let currentlyPlayingEpisodeUpdated = NSNotification.Name(rawValue: "SJCurrentlyPlayingEpisodeUpdated")
static let sleepTimerChanged = NSNotification.Name(rawValue: "SJSleepTimerChanged")
static let videoPlaybackEngineSwitched = NSNotification.Name(rawValue: "SJVideoPlaybackEngineSwitched")
/// Posted when the user toggles the audio/video shelf action. Distinct from
/// `videoPlaybackEngineSwitched` (runtime video detection) so it doesn't trigger auto-open behaviour.
static let videoRenderingToggled = NSNotification.Name(rawValue: "SJVideoRenderingToggled")

// episode notifications
static let episodePlayStatusChanged = NSNotification.Name(rawValue: "SJEpPlayStatusChanged")
Expand Down
16 changes: 15 additions & 1 deletion podcasts/Enumerations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ extension PlayerAction: AnalyticsDescribable {
[
.effects, .sleepTimer, .routePicker, .shareEpisode, .addToPlaylist, .download,
.transcript, .goToPodcast, .addBookmark, .markPlayed,
.starEpisode, .chromecast, .archive
.starEpisode, .chromecast, .archive, .videoToggle
]
}

Expand Down Expand Up @@ -291,6 +291,8 @@ extension PlayerAction: AnalyticsDescribable {
self = .download
case 13:
self = .addToPlaylist
case 14:
self = .videoToggle
default:
return nil
}
Expand Down Expand Up @@ -324,6 +326,8 @@ extension PlayerAction: AnalyticsDescribable {
return 12
case .addToPlaylist:
return 13
case .videoToggle:
return 14
}
}

Expand Down Expand Up @@ -372,6 +376,8 @@ extension PlayerAction: AnalyticsDescribable {
return episode.downloaded(pathFinder: DownloadManager.shared) ? L10n.removeDownload : (episode.isInDownloadProcess ? L10n.statusDownloading : L10n.download)
case .addToPlaylist:
return L10n.playlistManualEpisodeAddToPlaylist
case .videoToggle:
return PlaybackManager.shared.shouldRenderVideo() ? L10n.playerActionHideVideo : L10n.playerActionShowVideo
}
}

Expand Down Expand Up @@ -417,6 +423,8 @@ extension PlayerAction: AnalyticsDescribable {
return episode.downloaded(pathFinder: DownloadManager.shared) ? "episode-downloaded" : "episode-download"
case .addToPlaylist:
return "playlist-add-episode"
case .videoToggle:
return PlaybackManager.shared.shouldRenderVideo() ? "video_off" : "video_on"
}
}

Expand Down Expand Up @@ -451,13 +459,17 @@ extension PlayerAction: AnalyticsDescribable {
return episode.downloaded(pathFinder: DownloadManager.shared) ? "episode-downloaded" : "episode-download"
case .addToPlaylist:
return "playlist-add-episode"
case .videoToggle:
return PlaybackManager.shared.shouldRenderVideo() ? "video_off" : "video_on"
}
}

func canBePerformedOn(episode: BaseEpisode) -> Bool {
switch self {
case .starEpisode, .shareEpisode:
return episode is Episode
case .videoToggle:
return PlaybackManager.shared.canToggleVideoRendering()
default:
return true
}
Expand Down Expand Up @@ -497,6 +509,8 @@ extension PlayerAction: AnalyticsDescribable {
return "download"
case .addToPlaylist:
return "add_to_playlist"
case .videoToggle:
return "video_toggle"
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions podcasts/MiniPlayerToFullPlayerAnimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ class MiniPlayerToFullPlayerAnimator: NSObject, UIViewControllerAnimatedTransiti
transition == .presenting
}

private var isVideoPodcast: Bool {
PlaybackManager.shared.isCurrentEpisodeVideo()
private var isVideoShown: Bool {
PlaybackManager.shared.shouldRenderVideo()
}

init?(fromViewController: UIViewController, toViewController: UIViewController, transition: Transition, miniPlayerArtwork: PodcastImageView, fullPlayerArtwork: UIImageView, dismissVelocity: CGFloat = 0, fullPlayerYPosition: CGFloat = 0) {
Expand Down Expand Up @@ -143,7 +143,7 @@ class MiniPlayerToFullPlayerAnimator: NSObject, UIViewControllerAnimatedTransiti
let miniPlayerArtworkWithShadowFrame = miniPlayerArtwork.superview?.superview?.convert(miniPlayerArtwork.superview?.frame ?? .zero, to: nil) ?? .zero

// Artwork is not animated if it's a video podcast
if !isVideoPodcast {
if !isVideoShown {

// We need a mini player artwork snapshot when dismissing
// to ensure a smooth transition and that the shadows are
Expand Down Expand Up @@ -272,7 +272,7 @@ class MiniPlayerToFullPlayerAnimator: NSObject, UIViewControllerAnimatedTransiti

gradientView.layer.opacity = isPresenting ? 0 : 1
} completion: { _ in
self.fullPlayerArtwork.layer.opacity = !self.isVideoPodcast ? 1 : 0
self.fullPlayerArtwork.layer.opacity = !self.isVideoShown ? 1 : 0
self.miniPlayerArtwork.layer.opacity = 1

artwork?.removeFromSuperview()
Expand Down
22 changes: 21 additions & 1 deletion podcasts/NowPlayingPlayerItemViewController+Shelf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ protocol NowPlayingActionsDelegate: AnyObject {
func bookmarkTapped()
func transcriptTapped()
func downloadTapped()
func videoToggleTapped()
func sharedRoutePicker(largeSize: Bool) -> PCRoutePickerView
func presentManualPlaylistsChooser()
}
Expand All @@ -33,7 +34,7 @@ extension NowPlayingPlayerItemViewController: NowPlayingActionsDelegate {
#endif

// don't reload the actions unless we need to
if !lastShelfLoadState.updateRequired(shelfActions: actions, episodeUuid: playingEpisode.uuid, effectsOn: PlaybackManager.shared.effects().effectsEnabled(), sleepTimerOn: PlaybackManager.shared.sleepTimerActive(), episodeStarred: playingEpisode.keepEpisode, episodeStatus: playingEpisode.episodeStatus) { return }
if !lastShelfLoadState.updateRequired(shelfActions: actions, episodeUuid: playingEpisode.uuid, effectsOn: PlaybackManager.shared.effects().effectsEnabled(), sleepTimerOn: PlaybackManager.shared.sleepTimerActive(), episodeStarred: playingEpisode.keepEpisode, episodeStatus: playingEpisode.episodeStatus, videoToggleAvailable: PlaybackManager.shared.canToggleVideoRendering(), videoRenderingEnabled: PlaybackManager.shared.isVideoRenderingEnabled) { return }

// load the first 4 actions into the player, followed by an overflow icon
playerControlsStackView.removeAllSubviews()
Expand Down Expand Up @@ -184,6 +185,16 @@ extension NowPlayingPlayerItemViewController: NowPlayingActionsDelegate {

addToShelf(on: button)
#endif
case .videoToggle:
let renderingVideo = PlaybackManager.shared.shouldRenderVideo()
let button = UIButton(frame: CGRect.zero)
button.isPointerInteractionEnabled = true
button.imageView?.tintColor = ThemeColor.playerContrast02()
button.setImage(UIImage(named: renderingVideo ? "video_off" : "video_on"), for: .normal)
button.addTarget(self, action: #selector(videoToggleBtnTapped(_:)), for: .touchUpInside)
button.accessibilityLabel = renderingVideo ? L10n.playerActionHideVideo : L10n.playerActionShowVideo

addToShelf(on: button)
}

return true
Expand Down Expand Up @@ -280,6 +291,10 @@ extension NowPlayingPlayerItemViewController: NowPlayingActionsDelegate {
#endif
}

func videoToggleTapped() {
PlaybackManager.shared.toggleVideoRendering()
}

func downloadTapped() {
#if !APPCLIP
guard let episode = PlaybackManager.shared.currentEpisode() as? Episode else { return }
Expand Down Expand Up @@ -415,6 +430,11 @@ extension NowPlayingPlayerItemViewController: NowPlayingActionsDelegate {
#endif
}

@objc private func videoToggleBtnTapped(_ sender: UIButton) {
shelfButtonTapped(.videoToggle)
videoToggleTapped()
}

// MARK: - Sleep Timer

@objc func sleepTimerUpdated() {
Expand Down
18 changes: 15 additions & 3 deletions podcasts/NowPlayingPlayerItemViewController+Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension NowPlayingPlayerItemViewController {
addCustomObserver(Constants.Notifications.playbackPaused, selector: #selector(update(notification:)))
addCustomObserver(Constants.Notifications.playbackTrackChanged, selector: #selector(playbackTrackChanged))
addCustomObserver(Constants.Notifications.videoPlaybackEngineSwitched, selector: #selector(videoPlaybackEngineSwitched))
addCustomObserver(Constants.Notifications.videoRenderingToggled, selector: #selector(videoPlaybackEngineSwitched))
addCustomObserver(Constants.Notifications.podcastChaptersDidUpdate, selector: #selector(update(notification:)))
addCustomObserver(Constants.Notifications.googleCastStatusChanged, selector: #selector(update(notification:)))
addCustomObserver(Constants.Notifications.playbackEffectsChanged, selector: #selector(update(notification:)))
Expand All @@ -35,24 +36,35 @@ extension NowPlayingPlayerItemViewController {
}

@objc private func videoPlaybackEngineSwitched() {
floatingVideoView.player = PlaybackManager.shared.internalPlayerForVideoPlayback()
// Video may have been detected at runtime (e.g. an HLS stream), so refresh to reveal the view
// Video may have been detected at runtime (e.g. an HLS stream) or toggled via the shelf,
// so refresh to reveal or hide the view accordingly.
if PlaybackManager.shared.shouldRenderVideo() {
floatingVideoView.player = PlaybackManager.shared.internalPlayerForVideoPlayback()
}
update(notification: nil)
}

@objc func update(notification: NSNotification?) {
guard let playingEpisode = PlaybackManager.shared.currentEpisode() else { return }

if PlaybackManager.shared.isCurrentEpisodeVideo() {
if PlaybackManager.shared.shouldRenderVideo() {
if floatingVideoView.isHidden {
floatingVideoView.isHidden = false
floatingVideoView.player = PlaybackManager.shared.internalPlayerForVideoPlayback()
episodeImage.alpha = CGFloat.leastNonzeroMagnitude
}
} else {
let wasShowingVideo = !floatingVideoView.isHidden
floatingVideoView.player = nil
floatingVideoView.isHidden = true
episodeImage.alpha = 1.0
episodeImage.layer.opacity = 1
if wasShowingVideo {
// The artwork slot was invisible while the video was showing, so its aspect-fit
// subview may not have been laid out yet. Force a pass so the artwork (reloaded at
// the end of this method) appears the first time.
episodeImage.layoutIfNeeded()
}
}

let skipBackAmount = Settings.skipBackTime
Expand Down
2 changes: 1 addition & 1 deletion podcasts/NowPlayingPlayerItemViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ class NowPlayingPlayerItemViewController: PlayerItemViewController {
@objc private func videoTapped() {
guard PlaybackManager.shared.currentEpisode() != nil else { return }

if PlaybackManager.shared.isCurrentEpisodeVideo() {
if PlaybackManager.shared.shouldRenderVideo() {
let videoController = VideoViewController()
videoViewController = videoController
videoViewController?.modalTransitionStyle = .crossDissolve
Expand Down
30 changes: 30 additions & 0 deletions podcasts/PlaybackManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class PlaybackManager: ServerPlaybackDelegate {
/// Atomic because it's read from now-playing updates that can run off the main queue.
private let currentStreamContainsVideo = AtomicBool()

/// Whether the video of the current stream should be rendered. Defaults to on; the user can
/// switch an HLS video stream to audio-only via the player shelf toggle. Reset per episode.
private let videoRenderingEnabled = AtomicBool(true)

private let updateTimerInterval = 1 as TimeInterval

#if !os(watchOS)
Expand Down Expand Up @@ -751,6 +755,31 @@ class PlaybackManager: ServerPlaybackDelegate {
currentEpisode()?.videoPodcast() == true || currentStreamContainsVideo.value
}

/// Whether the video surface should currently be shown. Video content can be present
/// (`isCurrentEpisodeVideo()`) while the user has chosen to listen audio-only via the shelf toggle.
func shouldRenderVideo() -> Bool {
isCurrentEpisodeVideo() && videoRenderingEnabled.value
}

var isVideoRenderingEnabled: Bool {
videoRenderingEnabled.value
}

/// Whether the audio/video toggle should be offered for the current stream. Only HLS streams
/// found to carry video (not static video podcasts) can be switched to audio-only.
func canToggleVideoRendering() -> Bool {
FeatureFlag.hls.enabled && currentStreamContainsVideo.value && (currentEpisode() is Episode)
}

/// Toggles whether the current HLS stream's video surface is shown. When disabled the player
/// shows the episode artwork instead of the video; playback and video decoding are unaffected
/// (this is a display-only switch).
func toggleVideoRendering() {
guard canToggleVideoRendering() else { return }
videoRenderingEnabled.toggle()
NotificationCenter.postOnMainThread(notification: Constants.Notifications.videoRenderingToggled)
}
Comment thread
leandroalonso marked this conversation as resolved.
Comment thread
leandroalonso marked this conversation as resolved.

/// Called by the player when it detects video tracks in the stream it is playing.
/// Used for HLS streams whose video content isn't reflected in the episode's file type.
func handleVideoTracksDetected(forEpisode episodeUuid: String) {
Expand Down Expand Up @@ -1419,6 +1448,7 @@ class PlaybackManager: ServerPlaybackDelegate {
private func cleanupCurrentPlayer(permanent: Bool) {
haveCalledPlayerLoad = false
currentStreamContainsVideo.value = false
videoRenderingEnabled.value = true
seekingTo = PlaybackManager.notSeeking
FileLog.shared.addMessage("cleanupCurrentPlayer permanent? \(permanent)")
if let player {
Expand Down
15 changes: 15 additions & 0 deletions podcasts/PlayerImages.xcassets/video_off.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "video_off.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions podcasts/PlayerImages.xcassets/video_on.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "video_on.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading