Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
4 changes: 4 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 Down
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
20 changes: 17 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,37 @@ 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 appears.
if !showingCustomImage {
ImageManager.sharedManager.loadImage(episode: playingEpisode, imageView: artworkImageView, size: .page)
}
episodeImage.layoutIfNeeded()
Comment thread
leandroalonso marked this conversation as resolved.
Outdated
}
}

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.value.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.
12 changes: 6 additions & 6 deletions podcasts/PlayerZoomAnimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ final class PlayerZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning

private var isInteractive: Bool { interactiveVelocity != 0 }

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

private var presentDuration: TimeInterval { isInteractive ? 0.45 : 0.5 }
Expand Down Expand Up @@ -71,7 +71,7 @@ final class PlayerZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning
// Drop the mini-player handoff artwork for video — otherwise it would
// be installed on `episodeImage` in `willBeAddedToPlayer` and bleed
// through the floating video's letterbox bands.
if isVideoPodcast {
if isVideoShown {
toVC.nowPlayingItem.placeholderArtwork = nil
}
toVC.loadViewIfNeeded()
Expand Down Expand Up @@ -184,7 +184,7 @@ final class PlayerZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning
miniSnapshotController?.synchronizeScrollingTitleAnimation(with: miniVC)

let floating: UIImageView?
if isVideoPodcast {
if isVideoShown {
floating = nil
} else {
let art = makeFloatingArtwork(
Expand Down Expand Up @@ -238,7 +238,7 @@ final class PlayerZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning
self.miniSnapshotController = nil
// Restore the artwork only for audio — `update()` on the player
// controller already left video artwork effectively hidden.
if !self.isVideoPodcast {
if !self.isVideoShown {
toArtwork.alpha = 1
}
context.completeTransition(!context.transitionWasCancelled)
Expand Down Expand Up @@ -296,7 +296,7 @@ final class PlayerZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning
// Skip the morph entirely in that case and let the mini snapshot's
// own artwork appear in place instead. Video podcasts also skip the
// morph because `episodeImage` is hidden behind the floating video.
let shouldMorphArtwork = fromVC.tabsView.currentTab == 0 && !isVideoPodcast
let shouldMorphArtwork = fromVC.tabsView.currentTab == 0 && !isVideoShown
let sourceArtFrame = container.convert(fromArtwork.convert(fromArtwork.bounds, to: fromView), from: fromView)
let sourceArtCornerRadius = fromArtwork.layer.cornerRadius
let isMiniInline = mini.traitCollection.tabAccessoryEnvironment == .inline
Expand Down
Loading