mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-11-23 05:59:51 +00:00
Fixing Live TV since the refactor (#806)
This commit is contained in:
parent
978865995a
commit
4ac0547be8
@ -21,24 +21,12 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable {
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.fullScreen)
|
||||
var videoPlayer = makeVideoPlayer
|
||||
var liveVideoPlayer = makeLiveVideoPlayer
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
BasicNavigationViewCoordinator {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
VideoPlayer(manager: manager)
|
||||
.overlay {
|
||||
VideoPlayer.Overlay()
|
||||
}
|
||||
} else {
|
||||
NativeVideoPlayer(manager: manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
.inNavigationViewCoordinator()
|
||||
func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator<LiveVideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
@ -26,20 +26,8 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable {
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
BasicNavigationViewCoordinator {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
VideoPlayer(manager: manager)
|
||||
.overlay {
|
||||
VideoPlayer.Overlay()
|
||||
}
|
||||
} else {
|
||||
NativeVideoPlayer(manager: manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
.inNavigationViewCoordinator()
|
||||
func makeVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator<LiveVideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
@ -13,30 +13,18 @@ import SwiftUI
|
||||
final class LiveTVTabCoordinator: TabCoordinatable {
|
||||
|
||||
var child = TabChild(startingItems: [
|
||||
\LiveTVTabCoordinator.programs,
|
||||
\LiveTVTabCoordinator.channels,
|
||||
\LiveTVTabCoordinator.programs,
|
||||
\LiveTVTabCoordinator.home,
|
||||
])
|
||||
|
||||
@Route(tabItem: makeProgramsTab)
|
||||
var programs = makePrograms
|
||||
@Route(tabItem: makeChannelsTab)
|
||||
var channels = makeChannels
|
||||
@Route(tabItem: makeProgramsTab)
|
||||
var programs = makePrograms
|
||||
@Route(tabItem: makeHomeTab)
|
||||
var home = makeHome
|
||||
|
||||
func makePrograms() -> NavigationViewCoordinator<LiveTVProgramsCoordinator> {
|
||||
NavigationViewCoordinator(LiveTVProgramsCoordinator())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeProgramsTab(isActive: Bool) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "tv")
|
||||
L10n.programs.text
|
||||
}
|
||||
}
|
||||
|
||||
func makeChannels() -> NavigationViewCoordinator<LiveTVChannelsCoordinator> {
|
||||
NavigationViewCoordinator(LiveTVChannelsCoordinator())
|
||||
}
|
||||
@ -49,6 +37,18 @@ final class LiveTVTabCoordinator: TabCoordinatable {
|
||||
}
|
||||
}
|
||||
|
||||
func makePrograms() -> NavigationViewCoordinator<LiveTVProgramsCoordinator> {
|
||||
NavigationViewCoordinator(LiveTVProgramsCoordinator())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeProgramsTab(isActive: Bool) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "tv")
|
||||
L10n.programs.text
|
||||
}
|
||||
}
|
||||
|
||||
func makeHome() -> LiveTVHomeView {
|
||||
LiveTVHomeView()
|
||||
}
|
||||
|
59
Shared/Coordinators/LiveVideoPlayerCoordinator.swift
Normal file
59
Shared/Coordinators/LiveVideoPlayerCoordinator.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import PreferencesView
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \LiveVideoPlayerCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
let videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
self.videoPlayerManager = manager
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
#if os(iOS)
|
||||
|
||||
PreferencesView {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
LiveVideoPlayer(manager: self.videoPlayerManager)
|
||||
} else {
|
||||
LiveNativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
#else
|
||||
|
||||
PreferencesView {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
LiveVideoPlayer(manager: self.videoPlayerManager)
|
||||
} else {
|
||||
LiveNativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
@ -29,6 +29,8 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||
var serverList = makeServerList
|
||||
@Route(.fullScreen)
|
||||
var videoPlayer = makeVideoPlayer
|
||||
@Route(.fullScreen)
|
||||
var liveVideoPlayer = makeLiveVideoPlayer
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@ -99,4 +101,8 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||
func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {
|
||||
VideoPlayerCoordinator(manager: manager)
|
||||
}
|
||||
|
||||
func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> LiveVideoPlayerCoordinator {
|
||||
LiveVideoPlayerCoordinator(manager: manager)
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import Defaults
|
||||
import Factory
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Logging
|
||||
|
||||
extension BaseItemDto {
|
||||
|
||||
@ -38,8 +39,65 @@ extension BaseItemDto {
|
||||
|
||||
guard let matchingMediaSource = response.value.mediaSources?
|
||||
.first(where: { $0.eTag == mediaSource.eTag && $0.id == mediaSource.id })
|
||||
else { throw JellyfinAPIError("Matching media source not in playback info") }
|
||||
else {
|
||||
throw JellyfinAPIError("Matching media source not in playback info")
|
||||
}
|
||||
|
||||
return try matchingMediaSource.videoPlayerViewModel(with: self, playSessionID: response.value.playSessionID!)
|
||||
}
|
||||
|
||||
func liveVideoPlayerViewModel(with mediaSource: MediaSourceInfo, logger: Logger) async throws -> VideoPlayerViewModel {
|
||||
|
||||
let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType]
|
||||
// TODO: fix bitrate settings
|
||||
let tempOverkillBitrate = 360_000_000
|
||||
var profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
|
||||
if Defaults[.Experimental.liveTVForceDirectPlay] {
|
||||
profile.directPlayProfiles = [DirectPlayProfile(type: .video)]
|
||||
}
|
||||
|
||||
let userSession = Container.userSession.callAsFunction()
|
||||
|
||||
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
||||
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
||||
userID: userSession.user.id,
|
||||
maxStreamingBitrate: tempOverkillBitrate
|
||||
)
|
||||
|
||||
let request = Paths.getPostedPlaybackInfo(
|
||||
itemID: self.id!,
|
||||
parameters: playbackInfoParameters,
|
||||
playbackInfo
|
||||
)
|
||||
|
||||
let response = try await userSession.client.send(request)
|
||||
logger.debug("liveVideoPlayerViewModel response received")
|
||||
|
||||
var matchingMediaSource: MediaSourceInfo?
|
||||
if let responseMediaSources = response.value.mediaSources {
|
||||
for responseMediaSource in responseMediaSources {
|
||||
if let openToken = responseMediaSource.openToken, let mediaSourceId = mediaSource.id {
|
||||
if openToken.contains(mediaSourceId) {
|
||||
logger.debug("liveVideoPlayerViewModel found mediaSource with through openToken mediaSourceId match")
|
||||
matchingMediaSource = responseMediaSource
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchingMediaSource == nil && !responseMediaSources.isEmpty {
|
||||
// Didn't find a match, but maybe we can just grab the first item in the response
|
||||
matchingMediaSource = responseMediaSources.first
|
||||
logger.debug("liveVideoPlayerViewModel resorting to first media source in the response")
|
||||
}
|
||||
}
|
||||
guard let matchingMediaSource else {
|
||||
logger.debug("liveVideoPlayerViewModel no matchingMediaSource found, throwing error")
|
||||
throw JellyfinAPIError("Matching media source not in playback info")
|
||||
}
|
||||
|
||||
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned")
|
||||
return try matchingMediaSource.liveVideoPlayerViewModel(
|
||||
with: self,
|
||||
playSessionID: response.value.playSessionID!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,6 @@ extension MediaSourceInfo {
|
||||
playbackURL = fullTranscodeURL
|
||||
streamType = .transcode
|
||||
} else {
|
||||
|
||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
||||
isStatic: true,
|
||||
tag: item.etag,
|
||||
@ -66,4 +65,56 @@ extension MediaSourceInfo {
|
||||
streamType: streamType
|
||||
)
|
||||
}
|
||||
|
||||
func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
||||
let userSession = Container.userSession.callAsFunction()
|
||||
let playbackURL: URL
|
||||
let streamType: StreamType
|
||||
|
||||
if let transcodingURL, !Defaults[.Experimental.liveTVForceDirectPlay] {
|
||||
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
|
||||
else { throw JellyfinAPIError("Unable to construct transcoded url") }
|
||||
playbackURL = fullTranscodeURL
|
||||
streamType = .transcode
|
||||
} else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
||||
playbackURL = playbackUrl
|
||||
streamType = .direct
|
||||
} else {
|
||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
||||
isStatic: true,
|
||||
tag: item.etag,
|
||||
playSessionID: playSessionID,
|
||||
mediaSourceID: id
|
||||
)
|
||||
|
||||
let videoStreamRequest = Paths.getVideoStream(
|
||||
itemID: item.id!,
|
||||
parameters: videoStreamParameters
|
||||
)
|
||||
|
||||
guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else {
|
||||
throw JellyfinAPIError("Unable to construct transcoded url")
|
||||
}
|
||||
playbackURL = fullURL
|
||||
streamType = .direct
|
||||
}
|
||||
|
||||
let videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
|
||||
let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
|
||||
let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []
|
||||
|
||||
return .init(
|
||||
playbackURL: playbackURL,
|
||||
item: item,
|
||||
mediaSource: self,
|
||||
playSessionID: playSessionID,
|
||||
videoStreams: videoStreams,
|
||||
audioStreams: audioStreams,
|
||||
subtitleStreams: subtitleStreams,
|
||||
selectedAudioStreamIndex: defaultAudioStreamIndex ?? -1,
|
||||
selectedSubtitleStreamIndex: defaultSubtitleStreamIndex ?? -1,
|
||||
chapters: item.fullChapterInfo,
|
||||
streamType: streamType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
120
Shared/Objects/LiveTVChannelProgram.swift
Normal file
120
Shared/Objects/LiveTVChannelProgram.swift
Normal file
@ -0,0 +1,120 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
struct LiveTVChannelProgram: Hashable {
|
||||
let id = UUID()
|
||||
let channel: BaseItemDto
|
||||
let currentProgram: BaseItemDto?
|
||||
let programs: [BaseItemDto]
|
||||
}
|
||||
|
||||
extension LiveTVChannelProgram: Poster {
|
||||
var displayTitle: String {
|
||||
guard let currentProgram else { return "None" }
|
||||
return currentProgram.displayTitle
|
||||
}
|
||||
|
||||
var title: String {
|
||||
guard let currentProgram else { return "None" }
|
||||
switch currentProgram.type {
|
||||
case .episode:
|
||||
return currentProgram.seriesName ?? currentProgram.displayTitle
|
||||
default:
|
||||
return currentProgram.displayTitle
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
guard let currentProgram else { return "" }
|
||||
switch currentProgram.type {
|
||||
case .episode:
|
||||
return currentProgram.seasonEpisodeLabel
|
||||
case .video:
|
||||
return currentProgram.extraType?.displayTitle
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var showTitle: Bool {
|
||||
guard let currentProgram else { return false }
|
||||
switch currentProgram.type {
|
||||
case .episode, .series, .movie, .boxSet, .collectionFolder:
|
||||
return Defaults[.Customization.showPosterLabels]
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var typeSystemImage: String? {
|
||||
guard let currentProgram else { return nil }
|
||||
switch currentProgram.type {
|
||||
case .episode, .movie, .series:
|
||||
return "film"
|
||||
case .folder:
|
||||
return "folder.fill"
|
||||
case .person:
|
||||
return "person.fill"
|
||||
case .boxSet:
|
||||
return "film.stack"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
|
||||
guard let currentProgram else { return ImageSource() }
|
||||
switch currentProgram.type {
|
||||
case .episode:
|
||||
return currentProgram.seriesImageSource(.primary, maxWidth: maxWidth)
|
||||
case .folder:
|
||||
return ImageSource()
|
||||
default:
|
||||
return currentProgram.imageSource(.primary, maxWidth: maxWidth)
|
||||
}
|
||||
}
|
||||
|
||||
func landscapePosterImageSources(maxWidth: CGFloat, single: Bool = false) -> [ImageSource] {
|
||||
guard let currentProgram else { return [] }
|
||||
switch currentProgram.type {
|
||||
case .episode:
|
||||
if single || !Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] {
|
||||
return [currentProgram.imageSource(.primary, maxWidth: maxWidth)]
|
||||
} else {
|
||||
return [
|
||||
currentProgram.seriesImageSource(.thumb, maxWidth: maxWidth),
|
||||
currentProgram.seriesImageSource(.backdrop, maxWidth: maxWidth),
|
||||
currentProgram.imageSource(.primary, maxWidth: maxWidth),
|
||||
]
|
||||
}
|
||||
case .folder:
|
||||
return [currentProgram.imageSource(.primary, maxWidth: maxWidth)]
|
||||
case .video:
|
||||
return [currentProgram.imageSource(.primary, maxWidth: maxWidth)]
|
||||
default:
|
||||
return [
|
||||
currentProgram.imageSource(.thumb, maxWidth: maxWidth),
|
||||
currentProgram.imageSource(.backdrop, maxWidth: maxWidth),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
func cinematicPosterImageSources() -> [ImageSource] {
|
||||
guard let currentProgram else { return [] }
|
||||
switch currentProgram.type {
|
||||
case .episode:
|
||||
return [currentProgram.seriesImageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)]
|
||||
default:
|
||||
return [currentProgram.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)]
|
||||
}
|
||||
}
|
||||
}
|
@ -8,36 +8,20 @@
|
||||
|
||||
import Factory
|
||||
import Foundation
|
||||
import Get
|
||||
import JellyfinAPI
|
||||
|
||||
struct LiveTVChannelProgram: Hashable {
|
||||
let id = UUID()
|
||||
let channel: BaseItemDto
|
||||
let currentProgram: BaseItemDto?
|
||||
let programs: [BaseItemDto]
|
||||
extension Notification.Name {
|
||||
static let livePlayerDismissed = Notification.Name("livePlayerDismissed")
|
||||
}
|
||||
|
||||
final class LiveTVChannelsViewModel: ViewModel {
|
||||
final class LiveTVChannelsViewModel: PagingLibraryViewModel<LiveTVChannelProgram> {
|
||||
|
||||
@Published
|
||||
var channels: [BaseItemDto] = []
|
||||
@Published
|
||||
var channelPrograms: [LiveTVChannelProgram] = []
|
||||
|
||||
// @Published
|
||||
// var channelPrograms = [LiveTVChannelProgram]() {
|
||||
// didSet {
|
||||
// rows = []
|
||||
// let rowChannels = channelPrograms.chunked(into: 4)
|
||||
// for (index, rowChans) in rowChannels.enumerated() {
|
||||
// rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) }))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// @Published
|
||||
// var rows = [LiveTVChannelRow]()
|
||||
|
||||
private var programs = [BaseItemDto]()
|
||||
private var channelProgramsList = [BaseItemDto: [BaseItemDto]]()
|
||||
private var timer: Timer?
|
||||
@ -48,94 +32,35 @@ final class LiveTVChannelsViewModel: ViewModel {
|
||||
return df
|
||||
}
|
||||
|
||||
override init() {
|
||||
init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
getChannels()
|
||||
startScheduleCheckTimer()
|
||||
override func get(page: Int) async throws -> [LiveTVChannelProgram] {
|
||||
try await getChannelPrograms()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopScheduleCheckTimer()
|
||||
}
|
||||
|
||||
private func getGuideInfo() {
|
||||
Task {
|
||||
let request = Paths.getGuideInfo
|
||||
guard let _ = try? await userSession.client.send(request) else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.getChannels()
|
||||
}
|
||||
private func getChannelPrograms() async throws -> [LiveTVChannelProgram] {
|
||||
let _ = try await getGuideInfo()
|
||||
let channelsResponse = try await getChannels()
|
||||
guard let channels = channelsResponse.value.items, !channels.isEmpty else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func getChannels() {
|
||||
Task {
|
||||
let parameters = Paths.GetLiveTvChannelsParameters(
|
||||
userID: userSession.user.id,
|
||||
startIndex: 0,
|
||||
limit: 100,
|
||||
enableImageTypes: [.primary],
|
||||
fields: .MinimumFields,
|
||||
enableUserData: false,
|
||||
enableFavoriteSorting: true
|
||||
)
|
||||
|
||||
let request = Paths.getLiveTvChannels(parameters: parameters)
|
||||
guard let response = try? await userSession.client.send(request) else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.channels = response.value.items ?? []
|
||||
self.getPrograms()
|
||||
}
|
||||
let programsResponse = try await getPrograms(channelIds: channels.compactMap(\.id))
|
||||
let fetchedPrograms = programsResponse.value.items ?? []
|
||||
await MainActor.run {
|
||||
self.programs.append(contentsOf: fetchedPrograms)
|
||||
}
|
||||
}
|
||||
|
||||
private func getPrograms() {
|
||||
guard channels.isNotEmpty else {
|
||||
logger.debug("Cannot get programs, channels list empty.")
|
||||
return
|
||||
}
|
||||
let channelIds = channels.compactMap(\.id)
|
||||
|
||||
let minEndDate = Date.now.addComponentsToDate(hours: -1)
|
||||
let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
|
||||
|
||||
Task {
|
||||
let parameters = Paths.GetLiveTvProgramsParameters(
|
||||
channelIDs: channelIds,
|
||||
userID: userSession.user.id,
|
||||
maxStartDate: maxStartDate,
|
||||
minEndDate: minEndDate,
|
||||
sortBy: ["StartDate"]
|
||||
)
|
||||
|
||||
let request = Paths.getLiveTvPrograms(parameters: parameters)
|
||||
|
||||
do {
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.programs = response.value.items ?? []
|
||||
self.channelPrograms = self.processChannelPrograms()
|
||||
}
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processChannelPrograms() -> [LiveTVChannelProgram] {
|
||||
var channelPrograms = [LiveTVChannelProgram]()
|
||||
var newChannelPrograms = [LiveTVChannelProgram]()
|
||||
let now = Date()
|
||||
for channel in self.channels {
|
||||
let prgs = self.programs.filter { item in
|
||||
for channel in channels {
|
||||
let prgs = programs.filter { item in
|
||||
item.channelID == channel.id
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.channelProgramsList[channel] = prgs
|
||||
}
|
||||
|
||||
var currentPrg: BaseItemDto?
|
||||
for prg in prgs {
|
||||
@ -148,33 +73,86 @@ final class LiveTVChannelsViewModel: ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs))
|
||||
newChannelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs))
|
||||
}
|
||||
return channelPrograms
|
||||
|
||||
return newChannelPrograms
|
||||
}
|
||||
|
||||
private func getGuideInfo() async throws -> Response<GuideInfo> {
|
||||
let request = Paths.getGuideInfo
|
||||
return try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
func getChannels() async throws -> Response<BaseItemDtoQueryResult> {
|
||||
let parameters = Paths.GetLiveTvChannelsParameters(
|
||||
userID: userSession.user.id,
|
||||
startIndex: currentPage * pageSize,
|
||||
limit: pageSize,
|
||||
enableImageTypes: [.primary],
|
||||
fields: ItemFields.MinimumFields,
|
||||
enableUserData: false,
|
||||
enableFavoriteSorting: true
|
||||
)
|
||||
let request = Paths.getLiveTvChannels(parameters: parameters)
|
||||
return try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
private func getPrograms(channelIds: [String]) async throws -> Response<BaseItemDtoQueryResult> {
|
||||
let minEndDate = Date.now.addComponentsToDate(hours: -1)
|
||||
let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
|
||||
let parameters = Paths.GetLiveTvProgramsParameters(
|
||||
channelIDs: channelIds,
|
||||
userID: userSession.user.id,
|
||||
maxStartDate: maxStartDate,
|
||||
minEndDate: minEndDate,
|
||||
sortBy: ["StartDate"]
|
||||
)
|
||||
let request = Paths.getLiveTvPrograms(parameters: parameters)
|
||||
return try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
func startScheduleCheckTimer() {
|
||||
let date = Date()
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date)
|
||||
|
||||
// Run on 10th min of every hour
|
||||
// Run every minute
|
||||
guard let minute = components.minute else { return }
|
||||
components.second = 0
|
||||
components.minute = minute + (10 - (minute % 10))
|
||||
|
||||
components.minute = minute + (1 - (minute % 1))
|
||||
guard let nextMinute = calendar.date(from: components) else { return }
|
||||
|
||||
if let existingTimer = timer {
|
||||
existingTimer.invalidate()
|
||||
}
|
||||
timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in
|
||||
timer = Timer(fire: nextMinute, interval: 60, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.logger.debug("LiveTVChannels schedule check...")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let newChanPrgs = self.processChannelPrograms()
|
||||
DispatchQueue.main.async {
|
||||
self.channelPrograms = newChanPrgs
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
let channelProgramsCopy = self.channelPrograms
|
||||
var refreshedChannelPrograms: [LiveTVChannelProgram] = []
|
||||
for channelProgram in channelProgramsCopy {
|
||||
var currentPrg: BaseItemDto?
|
||||
let now = Date()
|
||||
for prg in channelProgram.programs {
|
||||
if let startDate = prg.startDate,
|
||||
let endDate = prg.endDate,
|
||||
now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate &&
|
||||
now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate
|
||||
{
|
||||
currentPrg = prg
|
||||
}
|
||||
}
|
||||
|
||||
refreshedChannelPrograms
|
||||
.append(LiveTVChannelProgram(
|
||||
channel: channelProgram.channel,
|
||||
currentProgram: currentPrg,
|
||||
programs: channelProgram.programs
|
||||
))
|
||||
}
|
||||
self.channelPrograms = refreshedChannelPrograms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
Shared/ViewModels/LiveVideoPlayerManager.swift
Normal file
31
Shared/ViewModels/LiveVideoPlayerManager.swift
Normal file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class LiveVideoPlayerManager: VideoPlayerManager {
|
||||
|
||||
@Published
|
||||
var program: LiveTVChannelProgram?
|
||||
@Published
|
||||
var dateFormatter = DateFormatter()
|
||||
|
||||
init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: LiveTVChannelProgram? = nil) {
|
||||
self.program = program
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
let viewModel = try await item.liveVideoPlayerViewModel(with: mediaSource, logger: logger)
|
||||
|
||||
await MainActor.run {
|
||||
self.currentViewModel = viewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -72,14 +72,10 @@ final class MediaViewModel: ViewModel, Stateful {
|
||||
mediaItems.removeAll()
|
||||
}
|
||||
|
||||
// TODO: atow, liveTV is removed because it wasn't fixed in time
|
||||
// after a giant refactor and to push an update
|
||||
let media: [MediaType] = try await getUserViews()
|
||||
.compactMap { userView in
|
||||
if userView.collectionType == "livetv" {
|
||||
// return .liveTV(userView)
|
||||
|
||||
return nil
|
||||
return .liveTV(userView)
|
||||
}
|
||||
|
||||
return .collectionFolder(userView)
|
||||
|
@ -68,7 +68,9 @@ class VideoPlayerViewModel: ViewModel {
|
||||
let configuration = VLCVideoPlayer.Configuration(url: playbackURL)
|
||||
configuration.autoPlay = true
|
||||
configuration.startTime = .seconds(max(0, item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]))
|
||||
configuration.audioIndex = .absolute(selectedAudioStreamIndex)
|
||||
if self.audioStreams[0].path != nil {
|
||||
configuration.audioIndex = .absolute(selectedAudioStreamIndex)
|
||||
}
|
||||
configuration.subtitleIndex = .absolute(selectedSubtitleStreamIndex)
|
||||
configuration.subtitleSize = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleSize])
|
||||
configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor)
|
||||
|
@ -6,7 +6,7 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CollectionView
|
||||
import CollectionVGrid
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
@ -34,18 +34,35 @@ struct LiveTVChannelsView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var channelsView: some View {
|
||||
CollectionView(items: viewModel.channelPrograms) { _, channelProgram, _ in
|
||||
channelCell(for: channelProgram)
|
||||
}
|
||||
.layout { _, layoutEnvironment in
|
||||
.grid(
|
||||
layoutEnvironment: layoutEnvironment,
|
||||
layoutMode: .fixedNumberOfColumns(4),
|
||||
itemSpacing: 8,
|
||||
lineSpacing: 16,
|
||||
itemSize: .estimated(400),
|
||||
sectionInsets: .zero
|
||||
)
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.elements.isNotEmpty {
|
||||
CollectionVGrid(
|
||||
$viewModel.elements,
|
||||
layout: .minWidth(400, itemSpacing: 16, lineSpacing: 4)
|
||||
) { program in
|
||||
channelCell(for: program)
|
||||
}
|
||||
.onReachedBottomEdge(offset: .offset(300)) {
|
||||
viewModel.send(.getNextPage)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.startScheduleCheckTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stopScheduleCheckTimer()
|
||||
}
|
||||
} else {
|
||||
VStack {
|
||||
Text(L10n.noResults)
|
||||
Button {
|
||||
viewModel.send(.refresh)
|
||||
} label: {
|
||||
Text(L10n.reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
@ -74,18 +91,32 @@ struct LiveTVChannelsView: View {
|
||||
timeFormatter: viewModel.timeFormatter
|
||||
),
|
||||
onSelect: { _ in
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!))
|
||||
guard let mediaSource = channel.mediaSources?.first else {
|
||||
return
|
||||
}
|
||||
viewModel.stopScheduleCheckTimer()
|
||||
router.route(
|
||||
to: \.liveVideoPlayer,
|
||||
LiveVideoPlayerManager(item: channel, mediaSource: mediaSource, program: channelProgram)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading && viewModel.channels.isEmpty {
|
||||
loadingView
|
||||
} else if viewModel.channels.isEmpty {
|
||||
noResultsView
|
||||
} else {
|
||||
channelsView
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.elements.isEmpty {
|
||||
loadingView
|
||||
} else if viewModel.elements.isEmpty {
|
||||
noResultsView
|
||||
} else {
|
||||
channelsView
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
if viewModel.state == .initial {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +129,7 @@ struct LiveTVChannelsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private extension BaseItemDto {
|
||||
extension BaseItemDto {
|
||||
func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram {
|
||||
var timeText = ""
|
||||
if let start = self.startDate {
|
||||
|
@ -36,7 +36,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
@ -61,7 +61,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
@ -86,7 +86,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
@ -111,7 +111,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
@ -136,7 +136,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
@ -161,7 +161,7 @@ struct LiveTVProgramsView: View {
|
||||
let channel = viewModel.findChannel(id: channelID),
|
||||
let mediaSource = channel.mediaSources?.first else { return }
|
||||
|
||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
} label: {
|
||||
LandscapeItemElement(item: item)
|
||||
}
|
||||
|
179
Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift
Normal file
179
Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift
Normal file
@ -0,0 +1,179 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct LiveNativeVideoPlayer: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
@State
|
||||
private var isPresentingOverlay: Bool = false
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
self.videoPlayerManager = manager
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playerView: some View {
|
||||
NativeVideoPlayerView(videoPlayerManager: videoPlayerManager)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ZStack {
|
||||
if let _ = videoPlayerManager.currentViewModel {
|
||||
playerView
|
||||
} else {
|
||||
VideoPlayer.LoadingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.ignoresSafeArea()
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveNativeVideoPlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let videoPlayerManager: VideoPlayerManager
|
||||
|
||||
func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController {
|
||||
UILiveNativeVideoPlayerViewController(manager: videoPlayerManager)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
class UILiveNativeVideoPlayerViewController: AVPlayerViewController {
|
||||
|
||||
let videoPlayerManager: VideoPlayerManager
|
||||
|
||||
private var rateObserver: NSKeyValueObservation!
|
||||
private var timeObserverToken: Any!
|
||||
|
||||
init(manager: VideoPlayerManager) {
|
||||
|
||||
self.videoPlayerManager = manager
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL)
|
||||
|
||||
newPlayer.allowsExternalPlayback = true
|
||||
newPlayer.appliesMediaSelectionCriteriaAutomatically = false
|
||||
newPlayer.currentItem?.externalMetadata = createMetadata()
|
||||
|
||||
rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in
|
||||
guard let newValue = change.newValue else { return }
|
||||
|
||||
if newValue == 0 {
|
||||
self.videoPlayerManager.onStateUpdated(newState: .paused)
|
||||
} else {
|
||||
self.videoPlayerManager.onStateUpdated(newState: .playing)
|
||||
}
|
||||
}
|
||||
|
||||
let time = CMTime(seconds: 0.1, preferredTimescale: 1000)
|
||||
|
||||
timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
|
||||
|
||||
guard let self else { return }
|
||||
|
||||
if time.seconds >= 0 {
|
||||
let newSeconds = Int(time.seconds)
|
||||
let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds)
|
||||
|
||||
self.videoPlayerManager.currentProgressHandler.progress = progress
|
||||
self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress
|
||||
self.videoPlayerManager.currentProgressHandler.seconds = newSeconds
|
||||
self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds
|
||||
}
|
||||
}
|
||||
|
||||
player = newPlayer
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stop()
|
||||
guard let timeObserverToken else { return }
|
||||
player?.removeTimeObserver(timeObserverToken)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
player?.seek(
|
||||
to: CMTimeMake(
|
||||
value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]),
|
||||
timescale: 1
|
||||
),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: { _ in
|
||||
self.play()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func createMetadata() -> [AVMetadataItem] {
|
||||
let allMetadata: [AVMetadataIdentifier: Any?] = [
|
||||
.commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle,
|
||||
.iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle,
|
||||
]
|
||||
|
||||
return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) }
|
||||
}
|
||||
|
||||
private func createMetadataItem(
|
||||
for identifier: AVMetadataIdentifier,
|
||||
value: Any?
|
||||
) -> AVMetadataItem? {
|
||||
guard let value else { return nil }
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
// Specify "und" to indicate an undefined language.
|
||||
item.extendedLanguageTag = "und"
|
||||
return item.copy() as? AVMetadataItem
|
||||
}
|
||||
|
||||
private func play() {
|
||||
player?.play()
|
||||
|
||||
videoPlayerManager.sendStartReport()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
player?.pause()
|
||||
|
||||
videoPlayerManager.sendStopReport()
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
extension LiveVideoPlayer.Overlay {
|
||||
|
||||
struct LiveBottomBarView: View {
|
||||
|
||||
@Environment(\.currentOverlayType)
|
||||
@Binding
|
||||
private var currentOverlayType
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
@Environment(\.isScrubbing)
|
||||
@Binding
|
||||
private var isScrubbing: Bool
|
||||
|
||||
@EnvironmentObject
|
||||
private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler
|
||||
@EnvironmentObject
|
||||
private var overlayTimer: TimerProxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
@EnvironmentObject
|
||||
private var viewModel: VideoPlayerViewModel
|
||||
|
||||
@FocusState
|
||||
private var isBarFocused: Bool
|
||||
|
||||
@ViewBuilder
|
||||
private var playbackStateView: some View {
|
||||
// if videoPlayerManager.state == .playing {
|
||||
// Image(systemName: "pause.circle")
|
||||
// } else if videoPlayerManager.state == .paused {
|
||||
// Image(systemName: "play.circle")
|
||||
// } else {
|
||||
// ProgressView()
|
||||
// }
|
||||
// videoPLayerManager access is giving an error here:
|
||||
// Fatal error: No ObservableObject of type LiveVideoPlayerManager found. A View.environmentObject(_:) for
|
||||
// LiveVideoPlayerManager may be missing as an ancestor of this view.
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) {
|
||||
|
||||
if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) {
|
||||
Text(subtitle.title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
dimensions[.leading]
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
|
||||
Text(viewModel.item.displayTitle)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
dimensions[.leading]
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VideoPlayer.Overlay.BarActionButtons()
|
||||
}
|
||||
|
||||
tvOSSliderView(value: $currentProgressHandler.scrubbedProgress)
|
||||
.onEditingChanged { isEditing in
|
||||
isScrubbing = isEditing
|
||||
|
||||
if isEditing {
|
||||
overlayTimer.pause()
|
||||
} else {
|
||||
overlayTimer.start(5)
|
||||
}
|
||||
}
|
||||
.focused($isBarFocused)
|
||||
.frame(height: 60)
|
||||
// .visible(isScrubbing || isPresentingOverlay)
|
||||
|
||||
HStack(spacing: 15) {
|
||||
|
||||
Text(currentProgressHandler.scrubbedSeconds.timeLabel)
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.white)
|
||||
|
||||
playbackStateView
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-"))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.onChange(of: isPresentingOverlay) { newValue in
|
||||
guard newValue else { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
// struct LoadingOverlay: View {
|
||||
//
|
||||
// @Environment(\.isPresentingOverlay)
|
||||
// @Binding
|
||||
// private var isPresentingOverlay
|
||||
//
|
||||
// @EnvironmentObject
|
||||
// private var proxy: VLCVideoPlayer.Proxy
|
||||
// @EnvironmentObject
|
||||
// private var router: LiveVideoPlayerCoordinator.Router
|
||||
//
|
||||
// @State
|
||||
// private var confirmCloseWorkItem: DispatchWorkItem?
|
||||
// @State
|
||||
// private var currentOverlayType: VideoPlayer.OverlayType = .main
|
||||
//
|
||||
// @StateObject
|
||||
// private var overlayTimer: TimerProxy = .init()
|
||||
//
|
||||
// var body: some View {
|
||||
// ZStack {
|
||||
//
|
||||
// ConfirmCloseOverlay()
|
||||
// .visible(currentOverlayType == .confirmClose)
|
||||
// }
|
||||
// .visible(isPresentingOverlay)
|
||||
// .animation(.linear(duration: 0.1), value: currentOverlayType)
|
||||
// .environment(\.currentOverlayType, $currentOverlayType)
|
||||
// .environmentObject(overlayTimer)
|
||||
// .onChange(of: currentOverlayType) { newValue in
|
||||
// if [.smallMenu, .chapters].contains(newValue) {
|
||||
// overlayTimer.pause()
|
||||
// } else if isPresentingOverlay {
|
||||
// overlayTimer.start(5)
|
||||
// }
|
||||
// }
|
||||
// .onChange(of: overlayTimer.isActive) { isActive in
|
||||
// guard !isActive else { return }
|
||||
//
|
||||
// withAnimation(.linear(duration: 0.3)) {
|
||||
// isPresentingOverlay = false
|
||||
// }
|
||||
// }
|
||||
// .onSelectPressed {
|
||||
// currentOverlayType = .main
|
||||
// isPresentingOverlay = true
|
||||
// overlayTimer.start(5)
|
||||
// }
|
||||
// .onMenuPressed {
|
||||
//
|
||||
// overlayTimer.start(5)
|
||||
// confirmCloseWorkItem?.cancel()
|
||||
//
|
||||
// if isPresentingOverlay && currentOverlayType == .confirmClose {
|
||||
// proxy.stop()
|
||||
// router.dismissCoordinator()
|
||||
// } else if isPresentingOverlay && currentOverlayType == .smallMenu {
|
||||
// currentOverlayType = .main
|
||||
// } else {
|
||||
// withAnimation {
|
||||
// currentOverlayType = .confirmClose
|
||||
// isPresentingOverlay = true
|
||||
// }
|
||||
//
|
||||
// let task = DispatchWorkItem {
|
||||
// withAnimation {
|
||||
// isPresentingOverlay = false
|
||||
// overlayTimer.stop()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// confirmCloseWorkItem = task
|
||||
//
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
struct LiveMainOverlay: View {
|
||||
|
||||
@Environment(\.currentOverlayType)
|
||||
@Binding
|
||||
private var currentOverlayType
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
@Environment(\.isScrubbing)
|
||||
@Binding
|
||||
private var isScrubbing: Bool
|
||||
|
||||
@EnvironmentObject
|
||||
private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler
|
||||
@EnvironmentObject
|
||||
private var overlayTimer: TimerProxy
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
||||
Spacer()
|
||||
|
||||
Overlay.LiveBottomBarView()
|
||||
.padding2()
|
||||
.padding2()
|
||||
.background {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black.opacity(0.8), location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(overlayTimer)
|
||||
}
|
||||
}
|
||||
}
|
101
Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift
Normal file
101
Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift
Normal file
@ -0,0 +1,101 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
struct Overlay: View {
|
||||
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
|
||||
@EnvironmentObject
|
||||
private var proxy: VLCVideoPlayer.Proxy
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
|
||||
@State
|
||||
private var confirmCloseWorkItem: DispatchWorkItem?
|
||||
@State
|
||||
private var currentOverlayType: VideoPlayer.OverlayType = .main
|
||||
|
||||
@StateObject
|
||||
private var overlayTimer: TimerProxy = .init()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
LiveMainOverlay()
|
||||
.visible(currentOverlayType == .main)
|
||||
|
||||
ConfirmCloseOverlay()
|
||||
.visible(currentOverlayType == .confirmClose)
|
||||
|
||||
VideoPlayer.SmallMenuOverlay()
|
||||
.visible(currentOverlayType == .smallMenu)
|
||||
|
||||
VideoPlayer.ChapterOverlay()
|
||||
.visible(currentOverlayType == .chapters)
|
||||
}
|
||||
.visible(isPresentingOverlay)
|
||||
.animation(.linear(duration: 0.1), value: currentOverlayType)
|
||||
.environment(\.currentOverlayType, $currentOverlayType)
|
||||
.environmentObject(overlayTimer)
|
||||
.onChange(of: currentOverlayType) { newValue in
|
||||
if [.smallMenu, .chapters].contains(newValue) {
|
||||
overlayTimer.pause()
|
||||
} else if isPresentingOverlay {
|
||||
overlayTimer.start(5)
|
||||
}
|
||||
}
|
||||
.onChange(of: overlayTimer.isActive) { isActive in
|
||||
guard !isActive else { return }
|
||||
|
||||
withAnimation(.linear(duration: 0.3)) {
|
||||
isPresentingOverlay = false
|
||||
}
|
||||
}
|
||||
// .onSelectPressed {
|
||||
// currentOverlayType = .main
|
||||
// isPresentingOverlay = true
|
||||
// overlayTimer.start(5)
|
||||
// }
|
||||
// .onMenuPressed {
|
||||
//
|
||||
// overlayTimer.start(5)
|
||||
// confirmCloseWorkItem?.cancel()
|
||||
//
|
||||
// if isPresentingOverlay && currentOverlayType == .confirmClose {
|
||||
// proxy.stop()
|
||||
// router.dismissCoordinator()
|
||||
// } else if isPresentingOverlay && currentOverlayType == .smallMenu {
|
||||
// currentOverlayType = .main
|
||||
// } else {
|
||||
// withAnimation {
|
||||
// currentOverlayType = .confirmClose
|
||||
// isPresentingOverlay = true
|
||||
// }
|
||||
//
|
||||
// let task = DispatchWorkItem {
|
||||
// withAnimation {
|
||||
// isPresentingOverlay = false
|
||||
// overlayTimer.stop()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// confirmCloseWorkItem = task
|
||||
//
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
120
Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift
Normal file
120
Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift
Normal file
@ -0,0 +1,120 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
struct LiveVideoPlayer: View {
|
||||
|
||||
enum OverlayType {
|
||||
case chapters
|
||||
case confirmClose
|
||||
case main
|
||||
case smallMenu
|
||||
}
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler
|
||||
@ObservedObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
@State
|
||||
private var isPresentingOverlay: Bool = false
|
||||
@State
|
||||
private var isScrubbing: Bool = false
|
||||
|
||||
@ViewBuilder
|
||||
private var playerView: some View {
|
||||
ZStack {
|
||||
VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration)
|
||||
.proxy(videoPlayerManager.proxy)
|
||||
.onTicksUpdated { ticks, _ in
|
||||
|
||||
let newSeconds = ticks / 1000
|
||||
let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds)
|
||||
currentProgressHandler.progress = newProgress
|
||||
currentProgressHandler.seconds = newSeconds
|
||||
|
||||
guard !isScrubbing else { return }
|
||||
currentProgressHandler.scrubbedProgress = newProgress
|
||||
}
|
||||
.onStateUpdated { state, _ in
|
||||
|
||||
videoPlayerManager.onStateUpdated(newState: state)
|
||||
|
||||
if state == .ended {
|
||||
if let _ = videoPlayerManager.nextViewModel,
|
||||
Defaults[.VideoPlayer.autoPlayEnabled]
|
||||
{
|
||||
videoPlayerManager.selectNextViewModel()
|
||||
} else {
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LiveVideoPlayer.Overlay()
|
||||
.eraseToAnyView()
|
||||
.environmentObject(videoPlayerManager)
|
||||
.environmentObject(videoPlayerManager.currentProgressHandler)
|
||||
.environmentObject(videoPlayerManager.currentViewModel!)
|
||||
.environmentObject(videoPlayerManager.proxy)
|
||||
.environment(\.isPresentingOverlay, $isPresentingOverlay)
|
||||
.environment(\.isScrubbing, $isScrubbing)
|
||||
}
|
||||
.onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in
|
||||
guard !newValue.isNaN && !newValue.isInfinite else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
videoPlayerManager.currentProgressHandler
|
||||
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingView: some View {
|
||||
VideoPlayer.LoadingView()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
Color.black
|
||||
|
||||
if let _ = videoPlayerManager.currentViewModel {
|
||||
playerView
|
||||
} else {
|
||||
loadingView
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onChange(of: isScrubbing) { newValue in
|
||||
guard !newValue else { return }
|
||||
videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
self.init(
|
||||
currentProgressHandler: manager.currentProgressHandler,
|
||||
videoPlayerManager: manager
|
||||
)
|
||||
}
|
||||
}
|
@ -37,8 +37,7 @@ struct NativeVideoPlayer: View {
|
||||
if let _ = videoPlayerManager.currentViewModel {
|
||||
playerView
|
||||
} else {
|
||||
// VideoPlayer.LoadingView()
|
||||
Text("Loading")
|
||||
VideoPlayer.LoadingView()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
@ -75,6 +75,9 @@ struct VideoPlayer: View {
|
||||
.environment(\.isScrubbing, $isScrubbing)
|
||||
}
|
||||
.onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in
|
||||
guard !newValue.isNaN && !newValue.isInfinite else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
videoPlayerManager.currentProgressHandler
|
||||
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
|
||||
|
@ -153,9 +153,29 @@
|
||||
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; };
|
||||
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; };
|
||||
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; };
|
||||
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; };
|
||||
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; };
|
||||
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; };
|
||||
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; };
|
||||
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
|
||||
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; };
|
||||
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; };
|
||||
C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */; };
|
||||
C46DD8D22A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */; };
|
||||
C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */; };
|
||||
C46DD8D72A8DC2990046A504 /* LiveVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */; };
|
||||
C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */; };
|
||||
C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */; };
|
||||
C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */; };
|
||||
C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */; };
|
||||
C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */; };
|
||||
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */; };
|
||||
C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */; };
|
||||
C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */; };
|
||||
C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; };
|
||||
C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */; };
|
||||
C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; };
|
||||
C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; };
|
||||
C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; };
|
||||
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; };
|
||||
C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; };
|
||||
@ -183,7 +203,6 @@
|
||||
E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; };
|
||||
E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; };
|
||||
E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; };
|
||||
E10706172943F2F900646DAF /* (null) in Sources */ = {isa = PBXBuildFile; };
|
||||
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; };
|
||||
E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; };
|
||||
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; };
|
||||
@ -935,7 +954,24 @@
|
||||
C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = "<group>"; };
|
||||
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = "<group>"; };
|
||||
C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = "<group>"; };
|
||||
C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = "<group>"; };
|
||||
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = "<group>"; };
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerManager.swift; sourceTree = "<group>"; };
|
||||
C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoadingOverlay.swift; sourceTree = "<group>"; };
|
||||
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
|
||||
C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveNativeVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveNativeVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = "<group>"; };
|
||||
C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = "<group>"; };
|
||||
C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTopBarView.swift; sourceTree = "<group>"; };
|
||||
C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = "<group>"; };
|
||||
C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = "<group>"; };
|
||||
C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = "<group>"; };
|
||||
C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = "<group>"; };
|
||||
C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelProgram.swift; sourceTree = "<group>"; };
|
||||
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = "<group>"; };
|
||||
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = "<group>"; };
|
||||
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = "<group>"; };
|
||||
@ -1453,9 +1489,12 @@
|
||||
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C46DD8E82A8FB4230046A504 /* LiveOverlays */,
|
||||
E10E842829A587090064EA49 /* Components */,
|
||||
E18A17F3298C68BF00C22F62 /* Overlays */,
|
||||
E1575EA5293E7D40001665B1 /* VideoPlayer.swift */,
|
||||
C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */,
|
||||
C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */,
|
||||
E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */,
|
||||
);
|
||||
path = VideoPlayer;
|
||||
@ -1481,6 +1520,7 @@
|
||||
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */,
|
||||
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
|
||||
BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
|
||||
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
|
||||
625CB57B2678CE1000530A6E /* ViewModel.swift */,
|
||||
);
|
||||
@ -1598,6 +1638,7 @@
|
||||
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */,
|
||||
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
|
||||
E15756352936856700976E1F /* VideoPlayerType.swift */,
|
||||
C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */,
|
||||
);
|
||||
path = Objects;
|
||||
sourceTree = "<group>";
|
||||
@ -1928,6 +1969,7 @@
|
||||
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */,
|
||||
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */,
|
||||
C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */,
|
||||
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
|
||||
E193D5412719404B00900D82 /* MainCoordinator */,
|
||||
62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */,
|
||||
E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */,
|
||||
@ -1962,6 +2004,54 @@
|
||||
path = VideoPlayerManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C44FA6DD2AACD15300EDEB56 /* PlaybackButtons */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */,
|
||||
C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */,
|
||||
);
|
||||
path = PlaybackButtons;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C46DD8DE2A8DC7600046A504 /* LiveOverlays */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C46DD8E32A8FA5C00046A504 /* Components */,
|
||||
C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */,
|
||||
C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */,
|
||||
);
|
||||
path = LiveOverlays;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C46DD8E32A8FA5C00046A504 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C44FA6DD2AACD15300EDEB56 /* PlaybackButtons */,
|
||||
C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */,
|
||||
C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C46DD8E82A8FB4230046A504 /* LiveOverlays */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C46DD8ED2A8FB4C60046A504 /* Components */,
|
||||
C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */,
|
||||
C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */,
|
||||
C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */,
|
||||
);
|
||||
path = LiveOverlays;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C46DD8ED2A8FB4C60046A504 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E107BB9127880A4000354E07 /* ItemViewModel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -2569,9 +2659,12 @@
|
||||
E193D5452719418B00900D82 /* VideoPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C46DD8DE2A8DC7600046A504 /* LiveOverlays */,
|
||||
E1559A74294D910A00C1FFBC /* Components */,
|
||||
E11245B228D97D4A00D8A977 /* Overlays */,
|
||||
E1D842162932AB8F00D1041A /* NativeVideoPlayer.swift */,
|
||||
C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */,
|
||||
C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */,
|
||||
E18A8E8228D60BC400333B9A /* VideoPlayer.swift */,
|
||||
E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */,
|
||||
E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */,
|
||||
@ -3291,6 +3384,11 @@
|
||||
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
|
||||
E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */,
|
||||
E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */,
|
||||
E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */,
|
||||
E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */,
|
||||
C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */,
|
||||
C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */,
|
||||
E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */,
|
||||
E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */,
|
||||
E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */,
|
||||
E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
|
||||
@ -3317,6 +3415,7 @@
|
||||
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
|
||||
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
|
||||
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
|
||||
C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
|
||||
C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
|
||||
E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */,
|
||||
E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */,
|
||||
@ -3333,6 +3432,7 @@
|
||||
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
|
||||
E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */,
|
||||
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
|
||||
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
|
||||
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
|
||||
E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
|
||||
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
|
||||
@ -3349,6 +3449,7 @@
|
||||
E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
|
||||
BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */,
|
||||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */,
|
||||
E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */,
|
||||
E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */,
|
||||
E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */,
|
||||
@ -3458,6 +3559,8 @@
|
||||
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */,
|
||||
E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */,
|
||||
E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */,
|
||||
C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */,
|
||||
C46DD8D72A8DC2990046A504 /* LiveVideoPlayer.swift in Sources */,
|
||||
E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */,
|
||||
E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */,
|
||||
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */,
|
||||
@ -3466,6 +3569,7 @@
|
||||
E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
|
||||
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
|
||||
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
|
||||
C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */,
|
||||
E1575E9F293E7B1E001665B1 /* Int.swift in Sources */,
|
||||
E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */,
|
||||
E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */,
|
||||
@ -3479,6 +3583,7 @@
|
||||
E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */,
|
||||
E133328929538D8D00EE76AB /* Files.swift in Sources */,
|
||||
E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */,
|
||||
C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */,
|
||||
E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */,
|
||||
E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */,
|
||||
E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */,
|
||||
@ -3574,6 +3679,7 @@
|
||||
files = (
|
||||
E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */,
|
||||
E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */,
|
||||
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */,
|
||||
5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
|
||||
E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
|
||||
E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */,
|
||||
@ -3588,7 +3694,6 @@
|
||||
E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */,
|
||||
621338932660107500A81A2A /* String.swift in Sources */,
|
||||
E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */,
|
||||
E10706172943F2F900646DAF /* (null) in Sources */,
|
||||
62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */,
|
||||
E122A9132788EAAD0060FA63 /* MediaStream.swift in Sources */,
|
||||
E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */,
|
||||
@ -3613,6 +3718,7 @@
|
||||
E18E0208288749200022598C /* BlurView.swift in Sources */,
|
||||
E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */,
|
||||
E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */,
|
||||
C46DD8D22A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */,
|
||||
E18ACA8B2A14301800BB4F35 /* ScalingButtonStyle.swift in Sources */,
|
||||
E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */,
|
||||
E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */,
|
||||
@ -3644,12 +3750,15 @@
|
||||
E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
|
||||
C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
|
||||
E133328829538D8D00EE76AB /* Files.swift in Sources */,
|
||||
E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */,
|
||||
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */,
|
||||
E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */,
|
||||
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */,
|
||||
E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */,
|
||||
E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */,
|
||||
E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */,
|
||||
E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */,
|
||||
C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */,
|
||||
E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */,
|
||||
E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */,
|
||||
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
|
||||
@ -3706,6 +3815,7 @@
|
||||
6264E88C273850380081A12A /* Strings.swift in Sources */,
|
||||
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */,
|
||||
E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */,
|
||||
C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
|
||||
E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
|
||||
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
|
||||
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
|
||||
@ -3718,6 +3828,7 @@
|
||||
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
|
||||
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */,
|
||||
E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */,
|
||||
C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */,
|
||||
E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */,
|
||||
E139CC1F28EC83E400688DE2 /* Int.swift in Sources */,
|
||||
E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */,
|
||||
@ -3737,6 +3848,8 @@
|
||||
E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */,
|
||||
E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */,
|
||||
E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */,
|
||||
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */,
|
||||
E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */,
|
||||
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
|
||||
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
|
||||
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
|
||||
@ -3750,6 +3863,8 @@
|
||||
E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */,
|
||||
E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */,
|
||||
E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */,
|
||||
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
|
||||
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
||||
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
|
||||
@ -3766,6 +3881,7 @@
|
||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
|
||||
E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */,
|
||||
C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */,
|
||||
E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */,
|
||||
E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */,
|
||||
E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */,
|
||||
@ -3873,6 +3989,8 @@
|
||||
E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */,
|
||||
E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */,
|
||||
E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */,
|
||||
E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */,
|
||||
C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */,
|
||||
E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
|
||||
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
|
||||
535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */,
|
||||
@ -3894,6 +4012,7 @@
|
||||
E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */,
|
||||
E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */,
|
||||
E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */,
|
||||
C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */,
|
||||
E11BDF972B865F550045C54A /* ItemTag.swift in Sources */,
|
||||
E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
|
||||
E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */,
|
||||
|
@ -62,77 +62,100 @@ struct LiveTVChannelItemWideElement: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack {
|
||||
HStack {
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .center) {
|
||||
ImageView(channel.imageURL(.primary, maxWidth: 128))
|
||||
ImageView(channel.imageURL(.primary, maxWidth: 56))
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0))
|
||||
VStack(alignment: .center) {
|
||||
Spacer()
|
||||
.frame(maxHeight: .infinity)
|
||||
GeometryReader { gp in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.gray)
|
||||
.opacity(0.4)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.jellyfinPurple)
|
||||
.frame(width: CGFloat(progressPercent * gp.size.width), height: 6)
|
||||
}
|
||||
}
|
||||
.frame(height: 6, alignment: .center)
|
||||
.padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4))
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
if loading {
|
||||
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 4)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : ""
|
||||
let channelName = "\(channelNumber)\(channel.name ?? "?")"
|
||||
Text(channelName)
|
||||
Text(channel.number != nil ? "\(channel.number ?? "") " : "")
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color.jellyfinPurple)
|
||||
.frame(alignment: .leading)
|
||||
.padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0))
|
||||
programLabel(
|
||||
timeText: currentProgramText.timeDisplay,
|
||||
titleText: currentProgramText.title,
|
||||
color: Color(.textHighlight)
|
||||
)
|
||||
if nextProgramsText.isNotEmpty {
|
||||
let nextItem = nextProgramsText[0]
|
||||
programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray)
|
||||
}
|
||||
if nextProgramsText.count > 1 {
|
||||
let nextItem2 = nextProgramsText[1]
|
||||
programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(alignment: .leading)
|
||||
.padding()
|
||||
.opacity(loading ? 0.5 : 1.0)
|
||||
}
|
||||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color("BackgroundSecondaryColor")))
|
||||
.frame(height: 128)
|
||||
.onTapGesture {
|
||||
onSelect { loadingState in
|
||||
loading = loadingState
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(channel.name ?? "")")
|
||||
.font(.body)
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color.jellyfinPurple)
|
||||
.frame(alignment: .leading)
|
||||
|
||||
progressBar()
|
||||
.padding(.top, 4)
|
||||
|
||||
HStack {
|
||||
Text(currentProgramText.timeDisplay)
|
||||
.font(.footnote)
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color("TextHighlightColor"))
|
||||
.frame(width: 38, alignment: .leading)
|
||||
|
||||
Text(currentProgramText.title)
|
||||
.font(.footnote)
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color("TextHighlightColor"))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
if !nextProgramsText.isEmpty {
|
||||
let nextItem = nextProgramsText[0]
|
||||
programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray)
|
||||
}
|
||||
if nextProgramsText.count > 1 {
|
||||
let nextItem2 = nextProgramsText[1]
|
||||
programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color("BackgroundColor"))
|
||||
.shadow(color: Color(.shadow), radius: 4, x: 0, y: 0)
|
||||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.secondarySystemFill))
|
||||
.onTapGesture {
|
||||
onSelect { loadingState in
|
||||
loading = loadingState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func progressBar() -> some View {
|
||||
VStack(alignment: .center) {
|
||||
GeometryReader { gp in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.gray)
|
||||
.opacity(0.4)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.jellyfinPurple)
|
||||
.frame(width: CGFloat(progressPercent * gp.size.width), height: 6)
|
||||
}
|
||||
}
|
||||
.frame(height: 6, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,12 +164,12 @@ struct LiveTVChannelItemWideElement: View {
|
||||
HStack(alignment: .top) {
|
||||
Text(timeText)
|
||||
.font(.footnote)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(color)
|
||||
.frame(width: 38, alignment: .leading)
|
||||
Text(titleText)
|
||||
.font(.footnote)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,11 @@ struct LiveTVChannelsView: View {
|
||||
timeFormatter: viewModel.timeFormatter
|
||||
),
|
||||
onSelect: { _ in
|
||||
mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!))
|
||||
guard let mediaSource = channel.mediaSources?.first else {
|
||||
return
|
||||
}
|
||||
viewModel.stopScheduleCheckTimer()
|
||||
mainRouter.route(to: \.liveVideoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource))
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -56,13 +60,16 @@ struct LiveTVChannelsView: View {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.channelPrograms.isNotEmpty {
|
||||
} else if viewModel.elements.isNotEmpty {
|
||||
CollectionVGrid(
|
||||
viewModel.channelPrograms,
|
||||
$viewModel.elements,
|
||||
layout: .minWidth(250, itemSpacing: 16, lineSpacing: 4)
|
||||
) { program in
|
||||
channelCell(for: program)
|
||||
}
|
||||
.onReachedBottomEdge(offset: .offset(300)) {
|
||||
viewModel.send(.getNextPage)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.startScheduleCheckTimer()
|
||||
}
|
||||
@ -73,13 +80,18 @@ struct LiveTVChannelsView: View {
|
||||
VStack {
|
||||
Text(L10n.noResults)
|
||||
Button {
|
||||
viewModel.getChannels()
|
||||
viewModel.send(.refresh)
|
||||
} label: {
|
||||
Text(L10n.reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
if viewModel.state == .initial {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
177
Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift
Normal file
177
Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift
Normal file
@ -0,0 +1,177 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct LiveNativeVideoPlayer: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
self.videoPlayerManager = manager
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playerView: some View {
|
||||
LiveNativeVideoPlayerView(videoPlayerManager: videoPlayerManager)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let _ = videoPlayerManager.currentViewModel {
|
||||
playerView
|
||||
} else {
|
||||
VideoPlayer.LoadingView()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden()
|
||||
.statusBarHidden()
|
||||
.ignoresSafeArea()
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveNativeVideoPlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController {
|
||||
UILiveNativeVideoPlayerViewController(manager: videoPlayerManager)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
class UILiveNativeVideoPlayerViewController: AVPlayerViewController {
|
||||
|
||||
let videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
private var rateObserver: NSKeyValueObservation!
|
||||
private var timeObserverToken: Any!
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
|
||||
self.videoPlayerManager = manager
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL)
|
||||
|
||||
newPlayer.allowsExternalPlayback = true
|
||||
newPlayer.appliesMediaSelectionCriteriaAutomatically = false
|
||||
newPlayer.currentItem?.externalMetadata = createMetadata()
|
||||
|
||||
// enable pip
|
||||
allowsPictureInPicturePlayback = true
|
||||
|
||||
rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in
|
||||
guard let newValue = change.newValue else { return }
|
||||
|
||||
if newValue == 0 {
|
||||
self.videoPlayerManager.onStateUpdated(newState: .paused)
|
||||
} else {
|
||||
self.videoPlayerManager.onStateUpdated(newState: .playing)
|
||||
}
|
||||
}
|
||||
|
||||
let time = CMTime(seconds: 0.1, preferredTimescale: 1000)
|
||||
|
||||
timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
|
||||
|
||||
guard let self else { return }
|
||||
|
||||
if time.seconds >= 0 {
|
||||
let newSeconds = Int(time.seconds)
|
||||
let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds)
|
||||
self.videoPlayerManager.currentProgressHandler.progress = progress
|
||||
self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress
|
||||
self.videoPlayerManager.currentProgressHandler.seconds = newSeconds
|
||||
self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds
|
||||
}
|
||||
}
|
||||
|
||||
player = newPlayer
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stop()
|
||||
guard let timeObserverToken else { return }
|
||||
player?.removeTimeObserver(timeObserverToken)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
player?.seek(
|
||||
to: CMTimeMake(
|
||||
value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]),
|
||||
timescale: 1
|
||||
),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: { _ in
|
||||
self.play()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func createMetadata() -> [AVMetadataItem] {
|
||||
let allMetadata: [AVMetadataIdentifier: Any?] = [
|
||||
.commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle,
|
||||
.iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle,
|
||||
]
|
||||
|
||||
return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) }
|
||||
}
|
||||
|
||||
private func createMetadataItem(
|
||||
for identifier: AVMetadataIdentifier,
|
||||
value: Any?
|
||||
) -> AVMetadataItem? {
|
||||
guard let value else { return nil }
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
// Specify "und" to indicate an undefined language.
|
||||
item.extendedLanguageTag = "und"
|
||||
return item.copy() as? AVMetadataItem
|
||||
}
|
||||
|
||||
private func play() {
|
||||
player?.play()
|
||||
|
||||
videoPlayerManager.sendStartReport()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
player?.pause()
|
||||
|
||||
videoPlayerManager.sendStopReport()
|
||||
}
|
||||
}
|
@ -0,0 +1,166 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer.Overlay {
|
||||
|
||||
struct LiveBottomBarView: View {
|
||||
|
||||
@Default(.VideoPlayer.Overlay.chapterSlider)
|
||||
private var chapterSlider
|
||||
@Default(.VideoPlayer.jumpBackwardLength)
|
||||
private var jumpBackwardLength
|
||||
@Default(.VideoPlayer.jumpForwardLength)
|
||||
private var jumpForwardLength
|
||||
@Default(.VideoPlayer.Overlay.playbackButtonType)
|
||||
private var playbackButtonType
|
||||
@Default(.VideoPlayer.Overlay.sliderType)
|
||||
private var sliderType
|
||||
@Default(.VideoPlayer.Overlay.timestampType)
|
||||
private var timestampType
|
||||
|
||||
@Environment(\.currentOverlayType)
|
||||
@Binding
|
||||
private var currentOverlayType
|
||||
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
@Environment(\.isScrubbing)
|
||||
@Binding
|
||||
private var isScrubbing: Bool
|
||||
|
||||
@EnvironmentObject
|
||||
private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler
|
||||
@EnvironmentObject
|
||||
private var overlayTimer: TimerProxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerProxy: VLCVideoPlayer.Proxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
@EnvironmentObject
|
||||
private var viewModel: VideoPlayerViewModel
|
||||
|
||||
@State
|
||||
private var currentChapter: ChapterInfo.FullInfo?
|
||||
|
||||
@ViewBuilder
|
||||
private var capsuleSlider: some View {
|
||||
CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress)
|
||||
.isEditing(_isScrubbing.wrappedValue)
|
||||
.trackMask {
|
||||
if chapterSlider && !viewModel.chapters.isEmpty {
|
||||
VideoPlayer.Overlay.ChapterTrack()
|
||||
.clipShape(Capsule())
|
||||
} else {
|
||||
Color.white
|
||||
}
|
||||
}
|
||||
.bottomContent {
|
||||
Group {
|
||||
switch timestampType {
|
||||
case .split:
|
||||
VideoPlayer.Overlay.SplitTimeStamp()
|
||||
case .compact:
|
||||
VideoPlayer.Overlay.CompactTimeStamp()
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
}
|
||||
.leadingContent {
|
||||
if playbackButtonType == .compact {
|
||||
VideoPlayer.Overlay.SmallPlaybackButtons()
|
||||
.padding(.trailing)
|
||||
.disabled(isScrubbing)
|
||||
}
|
||||
}
|
||||
.frame(height: 50)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbSlider: some View {
|
||||
ThumbSlider(progress: $currentProgressHandler.scrubbedProgress)
|
||||
.isEditing(_isScrubbing.wrappedValue)
|
||||
.trackMask {
|
||||
if chapterSlider && !viewModel.chapters.isEmpty {
|
||||
VideoPlayer.Overlay.ChapterTrack()
|
||||
.clipShape(Capsule())
|
||||
} else {
|
||||
Color.white
|
||||
}
|
||||
}
|
||||
.bottomContent {
|
||||
Group {
|
||||
switch timestampType {
|
||||
case .split:
|
||||
VideoPlayer.Overlay.SplitTimeStamp()
|
||||
case .compact:
|
||||
VideoPlayer.Overlay.CompactTimeStamp()
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
}
|
||||
.leadingContent {
|
||||
if playbackButtonType == .compact {
|
||||
VideoPlayer.Overlay.SmallPlaybackButtons()
|
||||
.padding(.trailing)
|
||||
.disabled(isScrubbing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if chapterSlider, let currentChapter {
|
||||
Button {
|
||||
currentOverlayType = .chapters
|
||||
overlayTimer.stop()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(currentChapter.displayTitle)
|
||||
.monospacedDigit()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.disabled(isScrubbing)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 5)
|
||||
.padding(.bottom, 15)
|
||||
|
||||
Group {
|
||||
switch sliderType {
|
||||
case .capsule: capsuleSlider
|
||||
case .thumb: thumbSlider
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: currentProgressHandler.scrubbedSeconds) { newValue in
|
||||
guard chapterSlider else { return }
|
||||
let newChapter = viewModel.chapter(from: newValue)
|
||||
if newChapter != currentChapter {
|
||||
if isScrubbing && Defaults[.hapticFeedback] {
|
||||
UIDevice.impact(.light)
|
||||
}
|
||||
|
||||
self.currentChapter = newChapter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer.Overlay {
|
||||
|
||||
struct LiveTopBarView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
@EnvironmentObject
|
||||
private var splitContentViewProxy: SplitContentViewProxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerProxy: VLCVideoPlayer.Proxy
|
||||
@EnvironmentObject
|
||||
private var viewModel: VideoPlayerViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 0) {
|
||||
HStack(alignment: .center) {
|
||||
Button {
|
||||
videoPlayerProxy.stop()
|
||||
router.dismissCoordinator {}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(ScalingButtonStyle(scale: 0.8))
|
||||
|
||||
Text(viewModel.item.displayTitle)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(1)
|
||||
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
dimensions[.leading]
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VideoPlayer.Overlay.BarActionButtons()
|
||||
.buttonStyle(ScalingButtonStyle(scale: 0.8))
|
||||
}
|
||||
.font(.system(size: 24))
|
||||
.tint(Color.white)
|
||||
.foregroundColor(Color.white)
|
||||
|
||||
if let subtitle = viewModel.item.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
dimensions[.leading]
|
||||
}
|
||||
.offset(y: -10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer.Overlay {
|
||||
|
||||
struct LiveLargePlaybackButtons: View {
|
||||
|
||||
@Default(.VideoPlayer.jumpBackwardLength)
|
||||
private var jumpBackwardLength
|
||||
@Default(.VideoPlayer.jumpForwardLength)
|
||||
private var jumpForwardLength
|
||||
@Default(.VideoPlayer.showJumpButtons)
|
||||
private var showJumpButtons
|
||||
|
||||
@EnvironmentObject
|
||||
private var timerProxy: TimerProxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
@EnvironmentObject
|
||||
private var videoPlayerProxy: VLCVideoPlayer.Proxy
|
||||
|
||||
@ViewBuilder
|
||||
private var jumpBackwardButton: some View {
|
||||
Button {
|
||||
videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue))
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Image(systemName: jumpBackwardLength.backwardImageLabel)
|
||||
.font(.system(size: 36, weight: .regular, design: .default))
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(ScalingButtonStyle(scale: 0.9))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
switch videoPlayerManager.state {
|
||||
case .playing:
|
||||
videoPlayerProxy.pause()
|
||||
default:
|
||||
videoPlayerProxy.play()
|
||||
}
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Group {
|
||||
switch videoPlayerManager.state {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
case .playing:
|
||||
Image(systemName: "pause.fill")
|
||||
default:
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 56, weight: .bold, design: .default))
|
||||
.padding()
|
||||
.transition(.opacity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(ScalingButtonStyle(scale: 0.9))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var jumpForwardButton: some View {
|
||||
Button {
|
||||
videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue))
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Image(systemName: jumpForwardLength.forwardImageLabel)
|
||||
.font(.system(size: 36, weight: .regular, design: .default))
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(ScalingButtonStyle(scale: 0.9))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
|
||||
Spacer(minLength: 100)
|
||||
|
||||
if showJumpButtons {
|
||||
jumpBackwardButton
|
||||
}
|
||||
|
||||
playButton
|
||||
.frame(minWidth: 100, maxWidth: 300)
|
||||
|
||||
if showJumpButtons {
|
||||
jumpForwardButton
|
||||
}
|
||||
|
||||
Spacer(minLength: 100)
|
||||
}
|
||||
.tint(Color.white)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
extension LiveVideoPlayer.Overlay {
|
||||
|
||||
struct LiveSmallPlaybackButtons: View {
|
||||
|
||||
@Default(.VideoPlayer.jumpBackwardLength)
|
||||
private var jumpBackwardLength
|
||||
@Default(.VideoPlayer.jumpForwardLength)
|
||||
private var jumpForwardLength
|
||||
@Default(.VideoPlayer.showJumpButtons)
|
||||
private var showJumpButtons
|
||||
|
||||
@EnvironmentObject
|
||||
private var timerProxy: TimerProxy
|
||||
@EnvironmentObject
|
||||
private var videoPlayerManager: VideoPlayerManager
|
||||
@EnvironmentObject
|
||||
private var videoPlayerProxy: VLCVideoPlayer.Proxy
|
||||
|
||||
@ViewBuilder
|
||||
private var jumpBackwardButton: some View {
|
||||
Button {
|
||||
videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue))
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Image(systemName: jumpBackwardLength.backwardImageLabel)
|
||||
.font(.system(size: 24, weight: .bold, design: .default))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
switch videoPlayerManager.state {
|
||||
case .playing:
|
||||
videoPlayerProxy.pause()
|
||||
default:
|
||||
videoPlayerProxy.play()
|
||||
}
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Group {
|
||||
switch videoPlayerManager.state {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
case .playing:
|
||||
Image(systemName: "pause.fill")
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.font(.system(size: 28, weight: .bold, design: .default))
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var jumpForwardButton: some View {
|
||||
Button {
|
||||
videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue))
|
||||
timerProxy.start(5)
|
||||
} label: {
|
||||
Image(systemName: jumpForwardLength.forwardImageLabel)
|
||||
.font(.system(size: 24, weight: .bold, design: .default))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 15) {
|
||||
|
||||
if showJumpButtons {
|
||||
jumpBackwardButton
|
||||
}
|
||||
|
||||
playButton
|
||||
|
||||
if showJumpButtons {
|
||||
jumpForwardButton
|
||||
}
|
||||
}
|
||||
.tint(Color.white)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
}
|
||||
}
|
127
Swiftfin/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift
Normal file
127
Swiftfin/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift
Normal file
@ -0,0 +1,127 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
struct LiveMainOverlay: View {
|
||||
|
||||
@Default(.VideoPlayer.Overlay.playbackButtonType)
|
||||
private var playbackButtonType
|
||||
|
||||
@Environment(\.currentOverlayType)
|
||||
@Binding
|
||||
private var currentOverlayType
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
@Environment(\.isScrubbing)
|
||||
@Binding
|
||||
private var isScrubbing: Bool
|
||||
@Environment(\.safeAreaInsets)
|
||||
private var safeAreaInsets
|
||||
|
||||
@EnvironmentObject
|
||||
private var splitContentViewProxy: SplitContentViewProxy
|
||||
|
||||
@StateObject
|
||||
private var overlayTimer: TimerProxy = .init()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
Overlay.LiveTopBarView()
|
||||
.if(UIDevice.hasNotch) { view in
|
||||
view.padding(safeAreaInsets.mutating(\.trailing, with: 0))
|
||||
.padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing)
|
||||
}
|
||||
.if(UIDevice.isPad) { view in
|
||||
view.padding(.top)
|
||||
.padding2(.horizontal)
|
||||
}
|
||||
.background {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.9), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.visible(playbackButtonType == .compact)
|
||||
}
|
||||
.visible(!isScrubbing && isPresentingOverlay)
|
||||
|
||||
Spacer()
|
||||
.allowsHitTesting(false)
|
||||
|
||||
Overlay.LiveBottomBarView()
|
||||
.if(UIDevice.hasNotch) { view in
|
||||
view.padding(safeAreaInsets.mutating(\.trailing, with: 0))
|
||||
.padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing)
|
||||
}
|
||||
.if(UIDevice.isPad) { view in
|
||||
view.padding2(.bottom)
|
||||
.padding2(.horizontal)
|
||||
}
|
||||
.background {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black.opacity(0.5), location: 0.5),
|
||||
.init(color: .black.opacity(0.5), location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.visible(isScrubbing || playbackButtonType == .compact)
|
||||
}
|
||||
.background {
|
||||
Color.clear
|
||||
.allowsHitTesting(true)
|
||||
.contentShape(Rectangle())
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
.visible(isScrubbing || isPresentingOverlay)
|
||||
}
|
||||
|
||||
if playbackButtonType == .large {
|
||||
LiveVideoPlayer.Overlay.LiveLargePlaybackButtons()
|
||||
.visible(!isScrubbing && isPresentingOverlay)
|
||||
}
|
||||
}
|
||||
.environmentObject(overlayTimer)
|
||||
.background {
|
||||
Color.black
|
||||
.opacity(!isScrubbing && playbackButtonType == .large && isPresentingOverlay ? 0.5 : 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.animation(.linear(duration: 0.1), value: isScrubbing)
|
||||
.onChange(of: isPresentingOverlay) { newValue in
|
||||
guard newValue, !isScrubbing else { return }
|
||||
overlayTimer.start(5)
|
||||
}
|
||||
.onChange(of: isScrubbing) { newValue in
|
||||
if newValue {
|
||||
overlayTimer.stop()
|
||||
} else {
|
||||
overlayTimer.start(5)
|
||||
}
|
||||
}
|
||||
.onChange(of: overlayTimer.isActive) { newValue in
|
||||
guard !newValue, !isScrubbing else { return }
|
||||
|
||||
withAnimation(.linear(duration: 0.3)) {
|
||||
isPresentingOverlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
36
Swiftfin/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift
Normal file
36
Swiftfin/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
struct Overlay: View {
|
||||
|
||||
@Environment(\.isPresentingOverlay)
|
||||
@Binding
|
||||
private var isPresentingOverlay
|
||||
|
||||
@State
|
||||
private var currentOverlayType: VideoPlayer.OverlayType = .main
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
LiveMainOverlay()
|
||||
.visible(currentOverlayType == .main)
|
||||
}
|
||||
.animation(.linear(duration: 0.1), value: currentOverlayType)
|
||||
.environment(\.currentOverlayType, $currentOverlayType)
|
||||
.onChange(of: isPresentingOverlay) { newValue in
|
||||
guard newValue else { return }
|
||||
currentOverlayType = .main
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
546
Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift
Normal file
546
Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift
Normal file
@ -0,0 +1,546 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import MediaPlayer
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
// TODO: organize
|
||||
// TODO: localization necessary for toast text?
|
||||
// TODO: entire gesture layer should be separate
|
||||
|
||||
struct LiveVideoPlayer: View {
|
||||
|
||||
@Default(.VideoPlayer.jumpBackwardLength)
|
||||
private var jumpBackwardLength
|
||||
@Default(.VideoPlayer.jumpForwardLength)
|
||||
private var jumpForwardLength
|
||||
|
||||
@Default(.VideoPlayer.Gesture.horizontalPanGesture)
|
||||
private var horizontalPanGesture
|
||||
@Default(.VideoPlayer.Gesture.horizontalSwipeGesture)
|
||||
private var horizontalSwipeGesture
|
||||
@Default(.VideoPlayer.Gesture.longPressGesture)
|
||||
private var longPressGesture
|
||||
@Default(.VideoPlayer.Gesture.multiTapGesture)
|
||||
private var multiTapGesture
|
||||
@Default(.VideoPlayer.Gesture.doubleTouchGesture)
|
||||
private var doubleTouchGesture
|
||||
@Default(.VideoPlayer.Gesture.pinchGesture)
|
||||
private var pinchGesture
|
||||
@Default(.VideoPlayer.Gesture.verticalPanGestureLeft)
|
||||
private var verticalGestureLeft
|
||||
@Default(.VideoPlayer.Gesture.verticalPanGestureRight)
|
||||
private var verticalGestureRight
|
||||
|
||||
@Default(.VideoPlayer.Subtitle.subtitleColor)
|
||||
private var subtitleColor
|
||||
@Default(.VideoPlayer.Subtitle.subtitleFontName)
|
||||
private var subtitleFontName
|
||||
@Default(.VideoPlayer.Subtitle.subtitleSize)
|
||||
private var subtitleSize
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: LiveVideoPlayerCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler
|
||||
@StateObject
|
||||
private var splitContentViewProxy: SplitContentViewProxy = .init()
|
||||
@ObservedObject
|
||||
private var videoPlayerManager: LiveVideoPlayerManager
|
||||
|
||||
@State
|
||||
private var audioOffset: Int = 0
|
||||
@State
|
||||
private var isAspectFilled: Bool = false
|
||||
@State
|
||||
private var isGestureLocked: Bool = false
|
||||
@State
|
||||
private var isPresentingOverlay: Bool = false
|
||||
@State
|
||||
private var isScrubbing: Bool = false
|
||||
@State
|
||||
private var playbackSpeed: Double = 1
|
||||
@State
|
||||
private var subtitleOffset: Int = 0
|
||||
|
||||
private let gestureStateHandler: VideoPlayer.GestureStateHandler = .init()
|
||||
private let updateViewProxy: UpdateViewProxy = .init()
|
||||
|
||||
@ViewBuilder
|
||||
private var playerView: some View {
|
||||
SplitContentView(splitContentWidth: 400)
|
||||
.proxy(splitContentViewProxy)
|
||||
.content {
|
||||
ZStack {
|
||||
VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration)
|
||||
.proxy(videoPlayerManager.proxy)
|
||||
.onTicksUpdated { ticks, _ in
|
||||
|
||||
let newSeconds = ticks / 1000
|
||||
var newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds)
|
||||
if newProgress.isInfinite || newProgress.isNaN {
|
||||
newProgress = 0
|
||||
}
|
||||
currentProgressHandler.progress = newProgress
|
||||
currentProgressHandler.seconds = newSeconds
|
||||
|
||||
guard !isScrubbing else { return }
|
||||
currentProgressHandler.scrubbedProgress = newProgress
|
||||
}
|
||||
.onStateUpdated { state, _ in
|
||||
|
||||
videoPlayerManager.onStateUpdated(newState: state)
|
||||
|
||||
if state == .ended {
|
||||
if let _ = videoPlayerManager.nextViewModel,
|
||||
Defaults[.VideoPlayer.autoPlayEnabled]
|
||||
{
|
||||
videoPlayerManager.selectNextViewModel()
|
||||
} else {
|
||||
router.dismissCoordinator {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GestureView()
|
||||
.onHorizontalPan {
|
||||
handlePan(action: horizontalPanGesture, state: $0, point: $1.x, velocity: $2, translation: $3)
|
||||
}
|
||||
.onHorizontalSwipe(translation: 100, velocity: 1500, sameSwipeDirectionTimeout: 1, handleHorizontalSwipe)
|
||||
.onLongPress(minimumDuration: 2, handleLongPress)
|
||||
.onPinch(handlePinchGesture)
|
||||
.onTap(samePointPadding: 10, samePointTimeout: 0.7, handleTapGesture)
|
||||
.onDoubleTouch(handleDoubleTouchGesture)
|
||||
.onVerticalPan {
|
||||
if $1.x <= 0.5 {
|
||||
handlePan(action: verticalGestureLeft, state: $0, point: -$1.y, velocity: $2, translation: $3)
|
||||
} else {
|
||||
handlePan(action: verticalGestureRight, state: $0, point: -$1.y, velocity: $2, translation: $3)
|
||||
}
|
||||
}
|
||||
|
||||
LiveVideoPlayer.Overlay()
|
||||
.environmentObject(splitContentViewProxy)
|
||||
.environmentObject(videoPlayerManager)
|
||||
.environmentObject(videoPlayerManager.currentProgressHandler)
|
||||
.environmentObject(videoPlayerManager.currentViewModel!)
|
||||
.environmentObject(videoPlayerManager.proxy)
|
||||
.environment(\.aspectFilled, $isAspectFilled)
|
||||
.environment(\.isPresentingOverlay, $isPresentingOverlay)
|
||||
.environment(\.isScrubbing, $isScrubbing)
|
||||
.environment(\.playbackSpeed, $playbackSpeed)
|
||||
}
|
||||
}
|
||||
.splitContent {
|
||||
// Wrapped due to navigation controller popping due to published changes
|
||||
WrappedView {
|
||||
NavigationViewCoordinator(PlaybackSettingsCoordinator()).view()
|
||||
}
|
||||
.cornerRadius(20, corners: [.topLeft, .bottomLeft])
|
||||
.environmentObject(splitContentViewProxy)
|
||||
.environmentObject(videoPlayerManager)
|
||||
.environmentObject(videoPlayerManager.currentViewModel)
|
||||
.environment(\.audioOffset, $audioOffset)
|
||||
.environment(\.subtitleOffset, $subtitleOffset)
|
||||
}
|
||||
.onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in
|
||||
guard !newValue.isNaN && !newValue.isInfinite else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
videoPlayerManager.currentProgressHandler
|
||||
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
UpdateView(proxy: updateViewProxy)
|
||||
.padding(.top)
|
||||
}
|
||||
.videoPlayerKeyCommands(
|
||||
gestureStateHandler: gestureStateHandler,
|
||||
updateViewProxy: updateViewProxy
|
||||
)
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let _ = videoPlayerManager.currentViewModel {
|
||||
playerView
|
||||
} else {
|
||||
VideoPlayer.LoadingView()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.ignoresSafeArea()
|
||||
.onChange(of: audioOffset) { newValue in
|
||||
videoPlayerManager.proxy.setAudioDelay(.ticks(newValue))
|
||||
}
|
||||
.onChange(of: isGestureLocked) { newValue in
|
||||
if newValue {
|
||||
updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked")
|
||||
} else {
|
||||
updateViewProxy.present(systemName: "lock.open.fill", title: "Gestures Unlocked")
|
||||
}
|
||||
}
|
||||
.onChange(of: isScrubbing) { newValue in
|
||||
guard !newValue else { return }
|
||||
videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds))
|
||||
}
|
||||
.onChange(of: subtitleColor) { newValue in
|
||||
videoPlayerManager.proxy.setSubtitleColor(.absolute(newValue.uiColor))
|
||||
}
|
||||
.onChange(of: subtitleFontName) { newValue in
|
||||
videoPlayerManager.proxy.setSubtitleFont(newValue)
|
||||
}
|
||||
.onChange(of: subtitleOffset) { newValue in
|
||||
videoPlayerManager.proxy.setSubtitleDelay(.ticks(newValue))
|
||||
}
|
||||
.onChange(of: subtitleSize) { newValue in
|
||||
videoPlayerManager.proxy.setSubtitleSize(.absolute(24 - newValue))
|
||||
}
|
||||
.onChange(of: videoPlayerManager.currentViewModel) { newViewModel in
|
||||
guard let newViewModel else { return }
|
||||
|
||||
videoPlayerManager.proxy.playNewMedia(newViewModel.vlcVideoPlayerConfiguration)
|
||||
|
||||
isAspectFilled = false
|
||||
audioOffset = 0
|
||||
subtitleOffset = 0
|
||||
}
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
init(manager: LiveVideoPlayerManager) {
|
||||
self.init(
|
||||
currentProgressHandler: manager.currentProgressHandler,
|
||||
videoPlayerManager: manager
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Gestures
|
||||
|
||||
// TODO: refactor to be split into other files
|
||||
// TODO: refactor so that actions are separate from the gesture calculations, so that actions are more general
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
private func handlePan(
|
||||
action: PanAction,
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
guard !isGestureLocked else { return }
|
||||
|
||||
switch action {
|
||||
case .none:
|
||||
return
|
||||
case .audioffset:
|
||||
audioOffsetAction(state: state, point: point, velocity: velocity, translation: translation)
|
||||
case .brightness:
|
||||
brightnessAction(state: state, point: point, velocity: velocity, translation: translation)
|
||||
case .playbackSpeed:
|
||||
playbackSpeedAction(state: state, point: point, velocity: velocity, translation: translation)
|
||||
case .scrub:
|
||||
scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 1)
|
||||
case .slowScrub:
|
||||
scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 0.1)
|
||||
case .subtitleOffset:
|
||||
subtitleOffsetAction(state: state, point: point, velocity: velocity, translation: translation)
|
||||
case .volume:
|
||||
volumeAction(state: state, point: point, velocity: velocity, translation: translation)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHorizontalSwipe(
|
||||
unitPoint: UnitPoint,
|
||||
direction: Bool,
|
||||
amount: Int
|
||||
) {
|
||||
guard !isGestureLocked else { return }
|
||||
|
||||
switch horizontalSwipeGesture {
|
||||
case .none:
|
||||
return
|
||||
case .jump:
|
||||
jumpAction(unitPoint: .init(x: direction ? 1 : 0, y: 0), amount: amount)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLongPress(point: UnitPoint) {
|
||||
switch longPressGesture {
|
||||
case .none:
|
||||
return
|
||||
case .gestureLock:
|
||||
guard !isPresentingOverlay else { return }
|
||||
isGestureLocked.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePinchGesture(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) {
|
||||
guard !isGestureLocked else { return }
|
||||
|
||||
switch pinchGesture {
|
||||
case .none:
|
||||
return
|
||||
case .aspectFill:
|
||||
aspectFillAction(state: state, unitPoint: unitPoint, scale: scale)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTapGesture(unitPoint: UnitPoint, taps: Int) {
|
||||
guard !isGestureLocked else {
|
||||
updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked")
|
||||
return
|
||||
}
|
||||
|
||||
if taps > 1 && multiTapGesture != .none {
|
||||
|
||||
withAnimation(.linear(duration: 0.1)) {
|
||||
isPresentingOverlay = false
|
||||
}
|
||||
|
||||
switch multiTapGesture {
|
||||
case .none:
|
||||
return
|
||||
case .jump:
|
||||
jumpAction(unitPoint: unitPoint, amount: taps - 1)
|
||||
}
|
||||
} else {
|
||||
withAnimation(.linear(duration: 0.1)) {
|
||||
isPresentingOverlay = !isPresentingOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDoubleTouchGesture(unitPoint: UnitPoint, taps: Int) {
|
||||
guard !isGestureLocked else {
|
||||
updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked")
|
||||
return
|
||||
}
|
||||
|
||||
switch doubleTouchGesture {
|
||||
case .none:
|
||||
return
|
||||
case .aspectFill: ()
|
||||
// aspectFillAction(state: state, unitPoint: unitPoint, scale: <#T##CGFloat#>)
|
||||
case .gestureLock:
|
||||
guard !isPresentingOverlay else { return }
|
||||
isGestureLocked.toggle()
|
||||
case .pausePlay: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
extension LiveVideoPlayer {
|
||||
|
||||
private func aspectFillAction(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) {
|
||||
guard state == .began || state == .changed else { return }
|
||||
if scale > 1, !isAspectFilled {
|
||||
isAspectFilled = true
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
videoPlayerManager.proxy.aspectFill(1)
|
||||
}
|
||||
} else if scale < 1, isAspectFilled {
|
||||
isAspectFilled = false
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
videoPlayerManager.proxy.aspectFill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func audioOffsetAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
if state == .began {
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beginningAudioOffset = audioOffset
|
||||
} else if state == .ended {
|
||||
return
|
||||
}
|
||||
|
||||
let newOffset = gestureStateHandler.beginningAudioOffset - round(
|
||||
Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000),
|
||||
toNearest: 100
|
||||
)
|
||||
|
||||
updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondLabel)
|
||||
audioOffset = clamp(newOffset, min: -30000, max: 30000)
|
||||
}
|
||||
|
||||
private func brightnessAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
if state == .began {
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beginningBrightnessValue = UIScreen.main.brightness
|
||||
} else if state == .ended {
|
||||
return
|
||||
}
|
||||
|
||||
let newBrightness = gestureStateHandler.beginningBrightnessValue - (gestureStateHandler.beginningHorizontalPanUnit - point)
|
||||
let clampedBrightness = clamp(newBrightness, min: 0, max: 1.0)
|
||||
let flashPercentage = Int(clampedBrightness * 100)
|
||||
|
||||
if flashPercentage >= 67 {
|
||||
updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%", iconSize: .init(width: 30, height: 30))
|
||||
} else if flashPercentage >= 33 {
|
||||
updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%")
|
||||
} else {
|
||||
updateViewProxy.present(systemName: "sun.min.fill", title: "\(flashPercentage)%", iconSize: .init(width: 20, height: 20))
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) {
|
||||
UIScreen.main.brightness = clampedBrightness
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: decide on overlay behavior?
|
||||
private func jumpAction(
|
||||
unitPoint: UnitPoint,
|
||||
amount: Int
|
||||
) {
|
||||
if unitPoint.x <= 0.5 {
|
||||
videoPlayerManager.proxy.jumpBackward(Int(jumpBackwardLength.rawValue))
|
||||
|
||||
updateViewProxy.present(systemName: "gobackward", title: "\(amount * Int(jumpBackwardLength.rawValue))s")
|
||||
} else {
|
||||
videoPlayerManager.proxy.jumpForward(Int(jumpForwardLength.rawValue))
|
||||
|
||||
updateViewProxy.present(systemName: "goforward", title: "\(amount * Int(jumpForwardLength.rawValue))s")
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackSpeedAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
if state == .began {
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beginningPlaybackSpeed = playbackSpeed
|
||||
} else if state == .ended {
|
||||
return
|
||||
}
|
||||
|
||||
let newPlaybackSpeed = round(
|
||||
gestureStateHandler.beginningPlaybackSpeed - Double(gestureStateHandler.beginningHorizontalPanUnit - point) * 2,
|
||||
toNearest: 0.25
|
||||
)
|
||||
let clampedPlaybackSpeed = clamp(newPlaybackSpeed, min: 0.25, max: 5.0)
|
||||
|
||||
updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel)
|
||||
|
||||
playbackSpeed = clampedPlaybackSpeed
|
||||
videoPlayerManager.proxy.setRate(.absolute(Float(clampedPlaybackSpeed)))
|
||||
}
|
||||
|
||||
private func scrubAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat,
|
||||
rate: CGFloat
|
||||
) {
|
||||
if state == .began {
|
||||
isScrubbing = true
|
||||
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beganPanWithOverlay = isPresentingOverlay
|
||||
} else if state == .ended {
|
||||
if !gestureStateHandler.beganPanWithOverlay {
|
||||
isPresentingOverlay = false
|
||||
}
|
||||
|
||||
isScrubbing = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let newProgress = gestureStateHandler.beginningPanProgress - (gestureStateHandler.beginningHorizontalPanUnit - point) * rate
|
||||
currentProgressHandler.scrubbedProgress = clamp(newProgress, min: 0, max: 1)
|
||||
}
|
||||
|
||||
private func subtitleOffsetAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
if state == .began {
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beginningSubtitleOffset = subtitleOffset
|
||||
} else if state == .ended {
|
||||
return
|
||||
}
|
||||
|
||||
let newOffset = gestureStateHandler.beginningSubtitleOffset - round(
|
||||
Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000),
|
||||
toNearest: 100
|
||||
)
|
||||
let clampedOffset = clamp(newOffset, min: -30000, max: 30000)
|
||||
|
||||
updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondLabel)
|
||||
|
||||
subtitleOffset = clampedOffset
|
||||
}
|
||||
|
||||
private func volumeAction(
|
||||
state: UIGestureRecognizer.State,
|
||||
point: CGFloat,
|
||||
velocity: CGFloat,
|
||||
translation: CGFloat
|
||||
) {
|
||||
let volumeView = MPVolumeView()
|
||||
guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { return }
|
||||
|
||||
if state == .began {
|
||||
gestureStateHandler.beginningPanProgress = currentProgressHandler.progress
|
||||
gestureStateHandler.beginningHorizontalPanUnit = point
|
||||
gestureStateHandler.beginningVolumeValue = AVAudioSession.sharedInstance().outputVolume
|
||||
} else if state == .ended {
|
||||
return
|
||||
}
|
||||
|
||||
let newVolume = gestureStateHandler.beginningVolumeValue - Float(gestureStateHandler.beginningHorizontalPanUnit - point)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) {
|
||||
slider.value = newVolume
|
||||
}
|
||||
}
|
||||
}
|
@ -171,6 +171,9 @@ struct VideoPlayer: View {
|
||||
.environment(\.subtitleOffset, $subtitleOffset)
|
||||
}
|
||||
.onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in
|
||||
guard !newValue.isNaN && !newValue.isInfinite else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
videoPlayerManager.currentProgressHandler
|
||||
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
|
||||
|
Loading…
Reference in New Issue
Block a user