From 2d5e34594a95b254209af156cd5d4937221b3732 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 22 Jul 2022 00:44:21 +0200 Subject: [PATCH] Live streams fix (fix #174, #175) --- Model/Applications/InvidiousAPI.swift | 22 ++++++++-- Model/Player/Backends/AVPlayerBackend.swift | 2 + Model/Player/Backends/MPVClient.swift | 2 + Model/Player/PlayerModel.swift | 16 ++++++- Shared/Player/Controls/ControlsOverlay.swift | 1 + Shared/Player/Controls/PlayerControls.swift | 14 ++++--- .../Player/{ => Controls}/TimelineView.swift | 42 +++++++++++++++---- Yattee.xcodeproj/project.pbxproj | 2 +- 8 files changed, 81 insertions(+), 20 deletions(-) rename Shared/Player/{ => Controls}/TimelineView.swift (89%) diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 162639d4..9a37f9e8 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -488,8 +488,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } private func extractStreams(from json: JSON) -> [Stream] { - extractFormatStreams(from: json["formatStreams"].arrayValue) + - extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) + let hls = extractHLSStreams(from: json) + if json["liveNow"].boolValue { + return hls + } + + return extractFormatStreams(from: json["formatStreams"].arrayValue) + + extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) + + hls } private func extractFormatStreams(from streams: [JSON]) -> [Stream] { @@ -538,6 +544,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } + private func extractHLSStreams(from content: JSON) -> [Stream] { + if let hlsURL = content.dictionaryValue["hlsUrl"]?.url { + return [Stream(hlsURL: hlsURL)] + } + + return [] + } + private func extractRelated(from content: JSON) -> [Video] { content .dictionaryValue["recommendedVideos"]? @@ -576,8 +590,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { private func extractCaptions(from content: JSON) -> [Captions] { content["captions"].arrayValue.compactMap { details in - guard let baseURL = account.url, - let url = URL(string: baseURL + details["url"].stringValue) else { return nil } + let baseURL = account.url + guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil } return Captions( label: details["label"].stringValue, diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index ad3e7200..c166de34 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -132,6 +132,8 @@ final class AVPlayerBackend: PlayerBackend { } func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) { + guard !model.live else { return } + avPlayer.seek( to: time, toleranceBefore: .secondsInDefaultTimescale(1), diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index 53365245..dfb60efd 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -140,6 +140,8 @@ final class MPVClient: ObservableObject { options.append("sub-files-append=\"\(subURL)\"") } + options.append("force-seekable=yes") + args.append(options.joined(separator: ",")) command("loadfile", args: args, returnValueCallback: completionHandler) diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index ccca821a..8cd6b285 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -273,7 +273,7 @@ final class PlayerModel: ObservableObject { } var videoDuration: TimeInterval? { - currentItem?.duration ?? currentVideo?.length ?? playerItemDuration?.seconds + playerItemDuration?.seconds ?? currentItem?.duration ?? currentVideo?.length } var time: CMTime? { @@ -284,6 +284,18 @@ final class PlayerModel: ObservableObject { currentVideo?.live ?? false } + var playingLive: Bool { + guard live, + let videoDuration = videoDuration, + let time = backend.currentTime?.seconds else { return false } + + return videoDuration - time < 30 + } + + var liveStreamInAVPlayer: Bool { + live && activeBackend == .appleAVPlayer + } + func togglePlay() { backend.togglePlay() } @@ -751,7 +763,7 @@ final class PlayerModel: ObservableObject { var nowPlayingInfo: [String: AnyObject] = [ MPMediaItemPropertyTitle: video.title as AnyObject, MPMediaItemPropertyArtist: video.author as AnyObject, - MPNowPlayingInfoPropertyIsLiveStream: video.live as AnyObject, + MPNowPlayingInfoPropertyIsLiveStream: live as AnyObject, MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject, MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject, MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject, diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 8968f13f..d1202607 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -54,6 +54,7 @@ struct ControlsOverlay: View { Text(backend.label) .padding(6) .foregroundColor(player.activeBackend == backend ? .accentColor : .secondary) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index 7e1e7e82..bbb6df88 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -317,11 +317,12 @@ struct PlayerControls: View { button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) { player.backend.seek(relative: .secondsInDefaultTimescale(-10)) } + .disabled(player.liveStreamInAVPlayer) #if os(tvOS) - .focused($focusedField, equals: .backward) + .focused($focusedField, equals: .backward) #else - .keyboardShortcut("k", modifiers: []) - .keyboardShortcut(KeyEquivalent.leftArrow, modifiers: []) + .keyboardShortcut("k", modifiers: []) + .keyboardShortcut(KeyEquivalent.leftArrow, modifiers: []) #endif } @@ -329,11 +330,12 @@ struct PlayerControls: View { button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) { player.backend.seek(relative: .secondsInDefaultTimescale(10)) } + .disabled(player.liveStreamInAVPlayer) #if os(tvOS) - .focused($focusedField, equals: .forward) + .focused($focusedField, equals: .forward) #else - .keyboardShortcut("l", modifiers: []) - .keyboardShortcut(KeyEquivalent.rightArrow, modifiers: []) + .keyboardShortcut("l", modifiers: []) + .keyboardShortcut(KeyEquivalent.rightArrow, modifiers: []) #endif } diff --git a/Shared/Player/TimelineView.swift b/Shared/Player/Controls/TimelineView.swift similarity index 89% rename from Shared/Player/TimelineView.swift rename to Shared/Player/Controls/TimelineView.swift index 07d577be..0f882851 100644 --- a/Shared/Player/TimelineView.swift +++ b/Shared/Player/Controls/TimelineView.swift @@ -108,6 +108,7 @@ struct TimelineView: View { .animation(.easeOut, value: thumbTooltipOffset) HStack(spacing: 4) { Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true) ?? playerTime.currentPlaybackTime) + .opacity(player.liveStreamInAVPlayer ? 0 : 1) .frame(minWidth: 35) #if os(tvOS) .font(.system(size: 20)) @@ -190,7 +191,7 @@ struct TimelineView: View { ) #endif } - + .opacity(player.liveStreamInAVPlayer ? 0 : 1) .overlay(GeometryReader { proxy in Color.clear .onAppear { @@ -209,12 +210,8 @@ struct TimelineView: View { }) #endif - Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime) - .clipShape(RoundedRectangle(cornerRadius: 3)) - .frame(minWidth: 35) - #if os(tvOS) - .font(.system(size: 20)) - #endif + durationView + .frame(minWidth: 30, alignment: .trailing) } .clipShape(RoundedRectangle(cornerRadius: 3)) .font(.system(size: 9).monospacedDigit()) @@ -222,6 +219,37 @@ struct TimelineView: View { } } + @ViewBuilder var durationView: some View { + if player.live { + if player.playingLive || player.activeBackend == .appleAVPlayer { + Text("LIVE") + .fontWeight(.bold) + .padding(2) + .foregroundColor(.white) + .background(RoundedRectangle(cornerRadius: 2).foregroundColor(.red)) + } else { + Button { + if let duration = player.videoDuration { + player.backend.seek(to: duration - 5) + } + } label: { + Text("LIVE") + .fontWeight(.bold) + .padding(2) + .foregroundColor(.primary) + .background(RoundedRectangle(cornerRadius: 2).strokeBorder(.red, lineWidth: 1).foregroundColor(.white)) + } + } + } else { + Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .frame(minWidth: 35) + #if os(tvOS) + .font(.system(size: 20)) + #endif + } + } + var tooltipVeritcalOffset: Double { var offset = -20.0 diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index c614f319..bead38af 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -1450,6 +1450,7 @@ 37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */, 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */, 37030FFE27B04DCC00ECDDAA /* PlayerControls.swift */, + 37E8B0EB27B326C00024006F /* TimelineView.swift */, 37648B68286CF5F1003D330B /* TVControls.swift */, 37E80F3B287B107F00561799 /* VideoDetailsOverlay.swift */, ); @@ -1499,7 +1500,6 @@ 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */, 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */, 37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */, - 37E8B0EB27B326C00024006F /* TimelineView.swift */, 37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */, 373031F22838388A000CFD59 /* PlayerLayerView.swift */, );