SvenTiigi / YouTubePlayerKit

A Swift Package to easily play YouTube videos 📺
https://sventiigi.github.io/YouTubePlayerKit/
MIT License
723 stars 64 forks source link

intermittently hitting YouTubePlayerKit.YouTubePlayer.Error.embeddedVideoPlayingNotAllowed error #74

Closed lundjordan closed 10 months ago

lundjordan commented 1 year ago

What happened?

Hi,

First, thank you for this library. It's amazing.

I am intermittently hitting a embeddedVideoPlayingNotAllowed error. Maybe 1 out of 10 times when trying to load a youtube video.

We use mostly in-house videos but sometimes use youtube videos for our fitness app. They are in a horizontal scrollview.

Code snippets below

player view:

struct YoutubePlayerHelperView: View {

    let youTubePlayer: YouTubePlayer
    var partOfPreview: Bool = false

    var body: some View {
        ZStack {
            YouTubePlayerView(self.youTubePlayer) { state in
                // Overlay ViewBuilder closure to place an overlay View
                // for the current `YouTubePlayer.State`
                switch state {
                case .idle:
                    ProgressView()
                case .ready:
                    EmptyView()
                case .error(let error):
                    Text(verbatim: "YouTube player couldn't be loaded")
                }
            }
            // hack to allow for ScrollView scrolling over video
            Color.red.opacity(0.0)
        }
    }

    static func initializePlayer(urlID: String) -> YouTubePlayer {
        return YouTubePlayer(
            source: .video(id: urlID),
            configuration: .init(
                showControls: false,
                loopEnabled: true
//                useModestBranding: true
            )
        )
    }

    static func getYoutubePlayer(urlID: String) -> YouTubePlayer {
        let youTubePlayer = initializePlayer(urlID: urlID)

        youTubePlayer.mute()
        youTubePlayer.play()

        return youTubePlayer
    }

}

called here:

struct ExerciseMiniVideoPlayer: View {
    ...
    ...
    var body: some View {
        VStack {
                YoutubePlayerHelperView(youTubePlayer: YoutubePlayerHelperView.getYoutubePlayer(urlID: youtubeURLID), partOfPreview: true)

from main view:

       GeometryReader { geometry in
            HStack {
                ScrollViewReader { scrollViewReaderValue in
                    ScrollView(.horizontal) {
                        HStack(spacing: 10) {
                            ForEach(workout.workoutPartsArray { workoutPart in

                                ExerciseMiniVideoPlayer(workoutPart: workoutPart, exerciseSet: ..., exercise:  exerciseID)
                                    .frame(width: (geometry.size.width * 0.9), height: ((geometry.size.width * 9) / 16) * 0.9)
                                    .cornerRadius(10)

here's a screenshot of it in use (when working). you can see the youtube video in bottom left corner.

Simulator Screenshot - iPhone 15 Pro - 2023-09-28 at 07 31 26

thanks in advance!

What are the steps to reproduce?

described above

What is the expected behavior?

described above

SvenTiigi commented 1 year ago

Hi @lundjordan,

Thanks for your detailed description. As the embeddedVideoPlayingNotAllowed error (Error-Code: 101) is coming directly from the YouTube Player iFrame JavaScript API there is sadly not much which can be easily fixed or debugged why the API returns this error code. Normally this error code will be returned when the creator of the video disabled the embedding option.

But from looking at the provided code snippet I would recommend to annotate the YouTubePlayer instance variable with an @StateObject inside your view.

struct YoutubePlayerHelperView: View {

    @StateObject    
    var youTubePlayer: YouTubePlayer

    ....

}

Additionally, as you app is showing multiple YouTubePlayerViews please keep in mind that simultaneous playback of multiple YouTube players is not supported (Read more)

lundjordan commented 1 year ago

Thank you for the prompt reply! 🙏🏼

Normally this error code will be returned when the creator of the video disabled the embedding option.

Interesting, in my case I can rule this out as the same video works most of the time and then once in a while, will failed to load with that error

But from looking at the provided code snippet I would recommend to annotate the YouTubePlayer instance variable with an @StateObject inside your view.

Thanks, I will try that.

Additionally, as you app is showing multiple YouTubePlayerViews please keep in mind that simultaneous playback of multiple YouTube players is not supported

Got it. I would actually be interested in not having the youtube videos autoplay but I have a conundrum. I have the videos in a ScrollView and need the user to be able to scroll horizontally across them all. If I don't autoplay and loopEnabled, I need to enable the user to have showControls access. But when I do that, they can no longer scroll with their finger over the video. With built in AVPlayer, a user can swipe anywhere on the video to initiate a scroll, and tap on the play button to play a video. For YouTubePlayerView I use a hack:

ZStack {
            YouTubePlayerView(self.youTubePlayer) // { state in ... etc

            // hack to allow for ScrollView scrolling over video
            Color.red.opacity(0.0)
 }

this allows me to autoplay the video, set showControls to false, and let the user scroll in ScrollView because they are actually touching a transparent color block overtop of the youtube video.

This might be scope creep of the bug but do you know of a way or support ability to use YoutubePlayer inside a scrollview and be able to scroll when touching inside the video frame? I bet you this would address the error issue as videos would only load/play as needed.

I'll upload an example of how it works right now:

https://github.com/SvenTiigi/YouTubePlayerKit/assets/1648433/f4074258-bc33-4c14-a53e-7ad02ffc5f3d

SvenTiigi commented 1 year ago

[...] do you know of a way or support ability to use YoutubePlayer inside a scrollview and be able to scroll when touching inside the video frame?

This is certainly kind a tricky as the underlying view of the YouTubePlayerView is a WKWebView and the displayed UI is therefore just HTML, CSS and JS which is makes it hard to implement something like Hit-Testing to check if a touch in a certain point of area should be redirected to next responder.

PS: Thanks for the sponsoring 🙏

tyler-fp commented 1 year ago

I am currently experiencing a simular issue. Not sure if it's the same embeddedVideoPlayingNotAllowed error, but basically the video will fail to load intermittently. My question is: is there a way to reload the video when this happens?

My ideal use case would be to listen to the statePublisher and display a refresh button when this returns an error. But I don't really see a way to act on this and to actually reload the YouTube player. Basically looking for a youTubePlayer.reload() function.

SvenTiigi commented 1 year ago

@tyler-fp a dedicated reload function is currently not available but would be a good addition to the YouTubePlayerKit 👍

For now you can simply re-apply the current configuration which destroys the underlying JavaScript YouTubePlayer instance and performs a re-setup.

extension YouTubePlayer {

    func reload() {
        self.update(configuration: self.configuration)
    }

}
tyler-fp commented 1 year ago

Ah perfect! Simple enough, thanks @SvenTiigi 👍

SvenTiigi commented 1 year ago

@tyler-fp FYI the reload function is now available with the latest release 1.5.3

lundjordan commented 11 months ago

Hi there,

I started poking this a bit again as some users have complained.

Below is my ugly attempt to reload:

My youtube player view:

struct YoutubePlayerHelperView: View {

    @StateObject var youTubePlayer: YouTubePlayer
    var partOfPreview: Bool = false

    @State private var retryTimes = 0

    var body: some View {
        ZStack {
            YouTubePlayerView(self.youTubePlayer) { state in
                // Overlay ViewBuilder closure to place an overlay View
                // for the current `YouTubePlayer.State`
                switch state {
                case .idle:
                    ProgressView()
                case .ready:
                    EmptyView()
                case .error( _):
                    self.tryReloadingView()
                }
            }
            // hack to allow for ScrollView scrolling over video
            Color.red.opacity(0.0)
        }
    }

    func tryReloadingView() -> some View {
        if retryTimes < 3 {
            self.youTubePlayer.reload()
            self.youTubePlayer.mute()
            self.youTubePlayer.play()
        }
        retryTimes += 1
        return EmptyView()
    }

    static func initializePlayer(urlID: String) -> YouTubePlayer {
        return YouTubePlayer(
            source: .video(id: urlID),
            configuration: .init(
                showControls: false,
                loopEnabled: true
//                useModestBranding: true
            )
        )
    }

    static func getYoutubePlayer(urlID: String) -> YouTubePlayer {
        let youTubePlayer = initializePlayer(urlID: urlID)

        youTubePlayer.mute()
        youTubePlayer.play()

        return youTubePlayer
    }

}

my view init call:

YoutubePlayerHelperView(youTubePlayer: YoutubePlayerHelperView.getYoutubePlayer(urlID: youtubeURLID))

behaviour I get:

  1. if the state never hits case .error, calling YoutubePlayerHelperView(youTubePlayer: YoutubePlayerHelperView.getYoutubePlayer(urlID: youtubeURLID)) will load the video and automatically start it playing it because of getYoutubePlayer() call.

  2. however, if we do hit the intermittent .error case, tryReloadingView() does successfully reload the video and removes the error view ( I instead see the youtube thumbnail ). But it does not automatically start playing. I was thinking about adding a DispatchQueue.main.asyncAfter sleep call inside tryReloadingView in between the reload() and the mute()/play() calls but this all feels like the wrong approach. I need it to autoplay and loop after reload because of the requirement to be able to embed videos inside a scrollview as described in https://github.com/SvenTiigi/YouTubePlayerKit/issues/74#issuecomment-1739826773

Any helps or tips would be appreciated!

lundjordan commented 11 months ago

I ended up doing:

func tryReloadingView() -> some View {
        retryTimes += 1
        if retryTimes <= 3 {
            self.youTubePlayer.reload()
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                self.youTubePlayer.mute()
                self.youTubePlayer.play()
            }
            return AnyView(EmptyView())
        } else {
            return AnyView(Text(verbatim: "YouTube player couldn't be loaded"))
        }
    }

which is not pretty but it does work at least. I noticed sleeping for 1 second was not enough. Needed to up it to 2 seconds.

SvenTiigi commented 11 months ago

Hi @lundjordan,

Based on the code snippets you provided I can give you some tips which might be helpful.

  1. Do not invoke any kind of side effect such as calling the tryReloadingView() function inside the placeholder overlay view builder closure of the YouTubePlayerView. This overlay view builder closure is called an indefinite number of times by the SwiftUI rendering engine. The best place to call a function such as tryReloadingView() is via an onReceive view modifier which listens to the YouTubePlayer.State.
struct YoutubePlayerHelperView: View {

    @StateObject
    var youTubePlayer: YouTubePlayer

    var body: some View {
        YouTubePlayerView(self.youTubePlayer) { state in
            // ...
        }
        .onReceive(self.youTubePlayer.statePublisher) { state in
            if case .error(let error) = state {
                // TODO: Try to reload if necessary
            }
        }
    }

}
  1. Please keep in mind that function calls as reload(), mute() or play() are asynchronous meaning it can take sometime until the operation finished due to the underlying JavaScript communication . It would be better to just call reload() and react to the change of the state of the YouTubePlayer via the statePublisher property to call the mute or play function when the YouTubePlayer is in a ready state.

  2. As the YouTubePlayer instance is marked with the @StateObject property wrapper and you passing the instance from the outside of the view you should be keeping this warning from the developer documentation in mind:

Use caution when doing this. SwiftUI only initializes a state object the first time you call its initializer in a given view. This ensures that the object provides stable storage even as the view’s inputs change. However, it might result in unexpected behavior or unwanted side effects if you explicitly initialize the state object.

Read more

lundjordan commented 11 months ago

The best place to call a function such as tryReloadingView() is via an onReceive view modifier which listens to the YouTubePlayer.State.

Ah, self.youTubePlayer.statePublisher. That makes sense, I changed it and this works! Thank you 🙏🏼

lundjordan commented 11 months ago

and thanks for the context with 2. and 3.

SvenTiigi commented 10 months ago

Closing this issue due to inactivity. Feel free to re-open the issue at any time.