Fixing Live TV since the refactor (#806)

This commit is contained in:
Julian Hays 2024-04-14 23:29:46 -05:00 committed by GitHub
parent 978865995a
commit 4ac0547be8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2706 additions and 247 deletions

View File

@ -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

View File

@ -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

View File

@ -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()
}

View 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
}
}

View File

@ -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)
}
}

View File

@ -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!
)
}
}

View File

@ -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
)
}
}

View 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)]
}
}
}

View File

@ -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
}
}
}

View 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
}
}
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}

View 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()
}
}

View File

@ -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 }
}
}
}
}

View File

@ -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)
// }
// }
// }
// }
}

View File

@ -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)
}
}
}

View 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)
// }
// }
}
}
}

View 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
)
}
}

View File

@ -37,8 +37,7 @@ struct NativeVideoPlayer: View {
if let _ = videoPlayerManager.currentViewModel {
playerView
} else {
// VideoPlayer.LoadingView()
Text("Loading")
VideoPlayer.LoadingView()
}
}
.navigationBarHidden(true)

View File

@ -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)

View File

@ -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 */,

View File

@ -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)
}
}

View File

@ -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)
}

View 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()
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}

View File

@ -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)