Merge pull request #426 from jhays/jhays/ios-livetv

LiveTV support for iOS
This commit is contained in:
Ethan Pippin 2022-06-15 17:35:16 -06:00 committed by GitHub
commit 8693326d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2389 additions and 153 deletions

View File

@ -20,6 +20,10 @@ final class LibraryListCoordinator: NavigationCoordinatable {
var search = makeSearch
@Route(.push)
var library = makeLibrary
#if os(iOS)
@Route(.push)
var liveTV = makeLiveTV
#endif
let viewModel: LibraryListViewModel
@ -35,6 +39,12 @@ final class LibraryListCoordinator: NavigationCoordinatable {
SearchCoordinator(viewModel: viewModel)
}
#if os(iOS)
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
#endif
@ViewBuilder
func makeStart() -> some View {
LibraryListView(viewModel: self.viewModel)

View File

@ -0,0 +1,30 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class LiveTVCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVCoordinator.start)
@Root
var start = makeStart
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
@ViewBuilder
func makeStart() -> some View {
LiveTVChannelsView()
}
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
}
}

View File

@ -17,6 +17,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
@Root
var rootLibrary = makeRootLibrary
@Route(.push)
var library = makeLibrary
@ -36,4 +38,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable {
func makeLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
}
func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
}
}

View File

@ -17,6 +17,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
@Root
var rootLibrary = makeRootLibrary
@Route(.push)
var library = makeLibrary
@ -36,4 +38,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable {
func makeLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
}
func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
}
}

View File

@ -0,0 +1,40 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start)
@Root
var start = makeStart
let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
if Defaults[.Experimental.liveTVNativePlayer] {
LiveTVNativePlayerView(viewModel: viewModel)
.navigationBarHidden(true)
.ignoresSafeArea()
} else {
LiveTVPlayerView(viewModel: viewModel)
.navigationBarHidden(true)
.ignoresSafeArea()
}
}
}

View File

@ -236,6 +236,7 @@ public extension BaseItemDto {
case boxset = "BoxSet"
case collectionFolder = "CollectionFolder"
case folder = "Folder"
case liveTV = "LiveTV"
case unknown
@ -247,6 +248,21 @@ public extension BaseItemDto {
return true
}
}
public init?(rawValue: String) {
let lowerCase = rawValue.lowercased()
switch lowerCase {
case "movie": self = .movie
case "season": self = .season
case "episode": self = .episode
case "series": self = .series
case "boxset": self = .boxset
case "collectionfolder": self = .collectionFolder
case "folder": self = .folder
case "livetv": self = .liveTV
default: self = .unknown
}
}
}
var itemType: ItemType {
@ -258,7 +274,7 @@ public extension BaseItemDto {
func portraitHeaderViewURL(maxWidth: Int) -> URL {
switch itemType {
case .movie, .season, .series, .boxset, .collectionFolder, .folder:
case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV:
return getPrimaryImage(maxWidth: maxWidth)
case .episode:
return getSeriesPrimaryImage(maxWidth: maxWidth)

View File

@ -188,6 +188,10 @@ extension UIScreen {
let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width
let itemSize = width * height
return Int(screenSize / itemSize)
#if os(tvOS)
return Int(screenSize / itemSize) * 2
#else
return Int(screenSize / itemSize)
#endif
}
}

View File

@ -20,7 +20,8 @@ struct LiveTVChannelRowCell: Hashable {
struct LiveTVChannelProgram: Hashable {
let id = UUID()
let channel: BaseItemDto
let program: BaseItemDto?
let currentProgram: BaseItemDto?
let programs: [BaseItemDto]
}
final class LiveTVChannelsViewModel: ViewModel {
@ -151,7 +152,7 @@ final class LiveTVChannelsViewModel: ViewModel {
}
}
channelPrograms.append(LiveTVChannelProgram(channel: channel, program: currentPrg))
channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs))
}
return channelPrograms
}

View File

@ -54,8 +54,8 @@ final class MovieLibrariesViewModel: ViewModel {
}
self.rows = self.calculateRows()
if self.libraries.count == 1, let library = self.libraries.first {
// show library
self.router?.route(to: \.library, library)
// make this library the root of this stack
self.router?.coordinator.root(\.rootLibrary, library)
}
}
})

View File

@ -54,8 +54,8 @@ final class TVLibrariesViewModel: ViewModel {
}
self.rows = self.calculateRows()
if self.libraries.count == 1, let library = self.libraries.first {
// show library
self.router?.route(to: \.library, library)
// make this library the root of this stack
self.router?.coordinator.root(\.rootLibrary, library)
}
}
})

View File

@ -8,6 +8,8 @@
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
struct LibraryListView: View {
@ -21,7 +23,13 @@ struct LibraryListView: View {
@Default(.Experimental.liveTVAlphaEnabled)
var liveTVAlphaEnabled
let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "livetv", "other"]
var supportedCollectionTypes: [BaseItemDto.ItemType] {
if liveTVAlphaEnabled {
return [.movie, .season, .series, .liveTV, .boxset, .unknown]
} else {
return [.movie, .season, .series, .boxset, .unknown]
}
}
var body: some View {
ScrollView {
@ -30,58 +38,37 @@ struct LibraryListView: View {
ForEach(viewModel.libraries.filter { [self] library in
let collectionType = library.collectionType ?? "other"
return self.supportedCollectionTypes.contains(collectionType)
let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown
return self.supportedCollectionTypes.contains(itemType)
}, id: \.id) { library in
if library.collectionType == "livetv" {
if liveTVAlphaEnabled {
Button {
self.mainCoordinator.root(\.liveTV)
}
label: {
ZStack {
HStack {
Spacer()
VStack {
Text(library.name ?? "")
.foregroundColor(.white)
.font(.title2)
.fontWeight(.semibold)
}
Spacer()
}.padding(32)
}
.frame(minWidth: 100, maxWidth: .infinity)
.frame(height: 100)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 5)
}
} else {
Button {
Button {
let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown
if itemType == .liveTV {
self.mainCoordinator.root(\.liveTV)
} else {
self.libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? ""))
}
label: {
ZStack {
HStack {
Spacer()
VStack {
Text(library.name ?? "")
.foregroundColor(.white)
.font(.title2)
.fontWeight(.semibold)
}
Spacer()
}.padding(32)
}
.frame(minWidth: 100, maxWidth: .infinity)
.frame(height: 100)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 5)
}
label: {
ZStack {
HStack {
Spacer()
VStack {
Text(library.name ?? "")
.foregroundColor(.white)
.font(.title2)
.fontWeight(.semibold)
}
Spacer()
}.padding(32)
}
.frame(minWidth: 100, maxWidth: .infinity)
.frame(height: 100)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 5)
}
} else {
ProgressView()

View File

@ -27,7 +27,7 @@ struct LibraryView: View {
var isShowingFilterView = false
var body: some View {
if viewModel.isLoading == true {
if viewModel.rows.isEmpty && viewModel.isLoading == true {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in

View File

@ -18,14 +18,25 @@ struct LiveTVChannelItemElement: View {
private var isFocused: Bool = false
var channel: BaseItemDto
var program: BaseItemDto?
var startString = " "
var endString = " "
var progressPercent = Double(0)
var currentProgram: BaseItemDto?
var currentProgramText: LiveTVChannelViewProgram
var nextProgramsText: [LiveTVChannelViewProgram]
var onSelect: (@escaping (Bool) -> Void) -> Void
var progressPercent: Double {
if let currentProgram = currentProgram {
let progressPercent = currentProgram.getLiveProgressPercentage()
if progressPercent > 1.0 {
return 1.0
} else {
return progressPercent
}
}
return 0
}
private var detailText: String {
guard let program = program else {
guard let program = currentProgram else {
return ""
}
var text = ""
@ -60,61 +71,62 @@ struct LiveTVChannelItemElement: View {
}.frame(alignment: .top)
Spacer()
}
VStack {
ImageView(channel.getPrimaryImage(maxWidth: 128))
.aspectRatio(contentMode: .fit)
.frame(width: 128, alignment: .center)
.padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0))
Text(channel.name ?? "?")
.font(.footnote)
.lineLimit(1)
.frame(alignment: .center)
Text(program?.name ?? L10n.notAvailableSlash)
.font(.body)
.lineLimit(1)
.foregroundColor(.green)
Text(detailText)
.font(.body)
.lineLimit(1)
.foregroundColor(.green)
Spacer()
HStack(alignment: .bottom) {
VStack {
Spacer()
HStack {
Text(startString)
.font(.footnote)
.lineLimit(1)
.frame(alignment: .leading)
Spacer()
GeometryReader { gp in
VStack {
ImageView(channel.getPrimaryImage(maxWidth: 192))
.aspectRatio(contentMode: .fit)
.frame(width: 192, alignment: .center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.init(top: 16, leading: 8, bottom: gp.size.height / 2, trailing: 0))
VStack {
Text(channel.name ?? "?")
.font(.footnote)
.lineLimit(1)
.frame(alignment: .center)
.foregroundColor(Color.jellyfinPurple)
.padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0))
Text(endString)
.font(.footnote)
.lineLimit(1)
.frame(alignment: .trailing)
}
GeometryReader { gp in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray)
.opacity(0.4)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
RoundedRectangle(cornerRadius: 6)
.fill(Color.jellyfinPurple)
.frame(width: CGFloat(progressPercent * gp.size.width), height: 12)
}
.frame(alignment: .bottom)
}
programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title,
color: Color("TextHighlightColor"), font: Font.system(size: 20, weight: .bold, design: .default))
if !nextProgramsText.isEmpty,
let nextItem = nextProgramsText[0]
{
programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray,
font: Font.system(size: 20, design: .default))
}
if nextProgramsText.count > 1,
let nextItem2 = nextProgramsText[1]
{
programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray,
font: Font.system(size: 20, design: .default))
}
}
.frame(maxHeight: .infinity, alignment: .top)
.padding(.init(top: gp.size.height / 2, leading: 16, bottom: 56, trailing: 16))
.opacity(loading ? 0.5 : 1.0)
}
.padding()
.opacity(loading ? 0.5 : 1.0)
if loading {
ProgressView()
}
VStack {
GeometryReader { gp in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray)
.opacity(0.4)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 8, maxHeight: 8)
RoundedRectangle(cornerRadius: 6)
.fill(Color.jellyfinPurple)
.frame(width: CGFloat(progressPercent * gp.size.width), height: 8)
}
.frame(maxHeight: .infinity, alignment: .bottom)
.padding(.init(top: 0, leading: 16, bottom: 32, trailing: 16))
}
}
}
.overlay(RoundedRectangle(cornerRadius: 20)
.stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4))
@ -133,4 +145,20 @@ struct LiveTVChannelItemElement: View {
}
}
}
@ViewBuilder
func programLabel(timeText: String, titleText: String, color: Color, font: Font) -> some View {
HStack(alignment: .top, spacing: 4) {
Text(timeText)
.font(font)
.lineLimit(1)
.foregroundColor(color)
.frame(width: 54, alignment: .leading)
Text(titleText)
.font(font)
.lineLimit(2)
.foregroundColor(color)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}

View File

@ -11,6 +11,8 @@ import JellyfinAPI
import SwiftUI
import SwiftUICollection
typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String)
struct LiveTVChannelsView: View {
@EnvironmentObject
var router: LiveTVChannelsCoordinator.Router
@ -53,23 +55,30 @@ struct LiveTVChannelsView: View {
func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View {
let item = cell.item
let channel = item.channel
if channel.type != "Folder" {
let progressPercent = item.program?.getLiveProgressPercentage() ?? 0
LiveTVChannelItemElement(channel: channel,
program: item.program,
startString: item.program?.getLiveStartTimeString(formatter: viewModel.timeFormatter) ?? " ",
endString: item.program?.getLiveEndTimeString(formatter: viewModel.timeFormatter) ?? " ",
progressPercent: progressPercent > 1.0 ? 1.0 : progressPercent,
onSelect: { loadingAction in
loadingAction(true)
self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in
self.router.route(to: \.videoPlayer, playerViewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
loadingAction(false)
}
}
})
let currentProgramDisplayText = item.currentProgram?
.programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "")
let nextItems = item.programs.filter { program in
guard let start = program.startDate else {
return false
}
guard let currentStart = item.currentProgram?.startDate else {
return false
}
return start > currentStart
}
LiveTVChannelItemElement(channel: channel,
currentProgram: item.currentProgram,
currentProgramText: currentProgramDisplayText,
nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter),
onSelect: { loadingAction in
loadingAction(true)
self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in
self.router.route(to: \.videoPlayer, playerViewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
loadingAction(false)
}
}
})
}
private func createGridLayout() -> NSCollectionLayoutSection {
@ -100,4 +109,43 @@ struct LiveTVChannelsView: View {
return section
}
private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] {
var programsDisplayText: [LiveTVChannelViewProgram] = []
for item in nextItems {
programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter))
}
return programsDisplayText
}
}
private extension BaseItemDto {
func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram {
var timeText = ""
if let start = self.startDate {
timeText.append(timeFormatter.string(from: start) + " ")
}
var displayText = ""
if let season = self.parentIndexNumber,
let episode = self.indexNumber
{
displayText.append("\(season)x\(episode) ")
} else if let episode = self.indexNumber {
displayText.append("\(episode) ")
}
if let name = self.name {
displayText.append("\(name) ")
}
if let title = self.episodeTitle {
displayText.append("\(title) ")
}
if let year = self.productionYear {
displayText.append("\(year) ")
}
if let rating = self.officialRating {
displayText.append("\(rating)")
}
return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText)
}
}

View File

@ -250,13 +250,26 @@
62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; };
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; };
AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; };
C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; };
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; };
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; };
C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9B284EA6EA00CABC12 /* SwiftUICollection */; };
C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9D285044C800CABC12 /* SwiftUICollection */; };
C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; };
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; };
C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; };
C4464953281616AE00DDB461 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; };
C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */; };
C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; };
C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; };
C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; };
C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */; };
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; };
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; };
C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; };
C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */; };
C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */; };
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; };
C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; };
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; };
@ -276,9 +289,10 @@
C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; };
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; };
C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0CE4A2848570700345D11 /* ASCollectionView */; };
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; };
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; };
C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; };
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; };
E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; };
E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; };
E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; };
@ -425,8 +439,6 @@
E1A2C15D279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; };
E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; };
E1A2C160279A7DCA005EC829 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutView.swift */; };
E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A99998271A3429008E78C0 /* SwiftUICollection */; };
E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A9999A271A343C008E78C0 /* SwiftUICollection */; };
E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButtonView.swift */; };
E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; };
E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; };
@ -732,6 +744,8 @@
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = "<group>"; };
62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = "<group>"; };
AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = "<group>"; };
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>"; };
C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesLibrariesCoordinator.swift; sourceTree = "<group>"; };
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = "<group>"; };
C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = "<group>"; };
@ -739,6 +753,11 @@
C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = "<group>"; };
C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = "<group>"; };
C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVNativePlayerViewController.swift; sourceTree = "<group>"; };
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = "<group>"; };
C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = "<group>"; };
C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerView.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>"; };
C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVNativeVideoPlayerView.swift; sourceTree = "<group>"; };
@ -757,6 +776,7 @@
C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = "<group>"; };
C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = "<group>"; };
E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = "<group>"; };
E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = "<group>"; };
@ -894,6 +914,7 @@
files = (
62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */,
E11D83AF278FA998006E9776 /* NukeUI in Frameworks */,
C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */,
62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */,
62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */,
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */,
@ -911,7 +932,6 @@
62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */,
62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */,
53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */,
E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */,
62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */,
E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */,
62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */,
@ -928,6 +948,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */,
62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */,
62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */,
E13DD3D327168E65009D4DAF /* Defaults in Frameworks */,
@ -944,7 +965,7 @@
62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */,
62666E3C27E503F200EC0ECD /* GoogleCastSDK.xcframework in Frameworks */,
62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */,
E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */,
C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */,
62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */,
E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */,
62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */,
@ -1475,6 +1496,7 @@
C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */,
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */,
C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */,
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */,
E193D5412719404B00900D82 /* MainCoordinator */,
C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */,
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */,
@ -1632,6 +1654,9 @@
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */,
6213388F265F83A900A81A2A /* LibraryListView.swift */,
53EE24E5265060780068F029 /* LibrarySearchView.swift */,
C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */,
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */,
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */,
53DF641D263D9C0600A7CD1A /* LibraryView.swift */,
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */,
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */,
@ -1730,10 +1755,13 @@
isa = PBXGroup;
children = (
E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */,
C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */,
E1002B692793E12E00E47059 /* Overlays */,
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */,
E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */,
C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */,
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */,
C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */,
);
path = VideoPlayer;
sourceTree = "<group>";
@ -1810,6 +1838,7 @@
E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = {
isa = PBXGroup;
children = (
C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */,
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */,
C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */,
E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */,
@ -1899,12 +1928,12 @@
E13DD3CC27164CA7009D4DAF /* CoreStore */,
E12186DD2718F1C50010884C /* Defaults */,
E1218C9D271A2CD600EA0737 /* CombineExt */,
E1A9999A271A343C008E78C0 /* SwiftUICollection */,
E178857C278037FD0094FBCF /* JellyfinAPI */,
E1AE8E7D2789136D00FBDDAA /* Nuke */,
E11D83AE278FA998006E9776 /* NukeUI */,
E1002B6A2793E36600E47059 /* Algorithms */,
E1347DB5279E3CA500BC6161 /* Puppy */,
C409CE9B284EA6EA00CABC12 /* SwiftUICollection */,
);
productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
@ -1936,7 +1965,6 @@
E13DD3D227168E65009D4DAF /* Defaults */,
E1B6DCE7271A23780015B715 /* CombineExt */,
E1B6DCE9271A23880015B715 /* SwiftyJSON */,
E1A99998271A3429008E78C0 /* SwiftUICollection */,
E10EAA44277BB646000269ED /* JellyfinAPI */,
E10EAA4C277BB716000269ED /* Sliders */,
E1AE8E7B2789135A00FBDDAA /* Nuke */,
@ -1944,6 +1972,8 @@
E1002B672793CFBA00E47059 /* Algorithms */,
62666E3827E502CE00EC0ECD /* SwizzleSwift */,
E1101176281B1E8A006A3584 /* Puppy */,
C4D0CE4A2848570700345D11 /* ASCollectionView */,
C409CE9D285044C800CABC12 /* SwiftUICollection */,
);
productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -2030,7 +2060,6 @@
E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */,
E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */,
E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */,
E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */,
E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */,
@ -2038,6 +2067,8 @@
E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */,
E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */,
C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */,
C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */,
);
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = "";
@ -2069,6 +2100,7 @@
53913C0526D323FE00EB3286 /* Localizable.strings in Resources */,
53913BFF26D323FE00EB3286 /* Localizable.strings in Resources */,
53913C0E26D323FE00EB3286 /* Localizable.strings in Resources */,
C4464953281616AE00DDB461 /* Assets.xcassets in Resources */,
53913BF026D323FE00EB3286 /* Localizable.strings in Resources */,
53913C0826D323FE00EB3286 /* Localizable.strings in Resources */,
53913C1126D323FE00EB3286 /* Localizable.strings in Resources */,
@ -2243,6 +2275,7 @@
62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */,
E193D54B271941D300900D82 /* ServerListView.swift in Sources */,
53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */,
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */,
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */,
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
@ -2268,7 +2301,6 @@
E193D5502719430400900D82 /* ServerDetailView.swift in Sources */,
E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */,
C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */,
E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */,
E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */,
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
@ -2378,9 +2410,11 @@
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */,
62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */,
62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */,
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */,
53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */,
C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */,
E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */,
62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */,
C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */,
@ -2399,9 +2433,11 @@
5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */,
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */,
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */,
E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */,
C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */,
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */,
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */,
E19169CE272514760085832A /* HTTPScheme.swift in Sources */,
@ -2413,12 +2449,14 @@
E1047E2027E584AF00CB0D4A /* BlurHashView.swift in Sources */,
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */,
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */,
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */,
E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */,
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */,
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */,
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
6264E88C273850380081A12A /* Strings.swift in Sources */,
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */,
@ -2469,12 +2507,14 @@
C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */,
E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */,
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */,
E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */,
62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */,
E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */,
E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */,
E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */,
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */,
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */,
E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */,
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */,
@ -2486,7 +2526,9 @@
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */,
62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */,
5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */,
C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */,
E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */,
C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */,
E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
@ -3083,14 +3125,22 @@
minimumVersion = 2.0.2;
};
};
C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = {
C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ABJC/SwiftUICollection";
repositoryURL = "https://github.com/defagos/SwiftUICollection";
requirement = {
branch = master;
kind = branch;
};
};
C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apptekstudios/ASCollectionView";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-algorithms.git";
@ -3227,6 +3277,21 @@
package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */;
productName = Stinsen;
};
C409CE9B284EA6EA00CABC12 /* SwiftUICollection */ = {
isa = XCSwiftPackageProductDependency;
package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */;
productName = SwiftUICollection;
};
C409CE9D285044C800CABC12 /* SwiftUICollection */ = {
isa = XCSwiftPackageProductDependency;
package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */;
productName = SwiftUICollection;
};
C4D0CE4A2848570700345D11 /* ASCollectionView */ = {
isa = XCSwiftPackageProductDependency;
package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */;
productName = ASCollectionView;
};
E1002B672793CFBA00E47059 /* Algorithms */ = {
isa = XCSwiftPackageProductDependency;
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
@ -3322,16 +3387,6 @@
package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;
productName = JellyfinAPI;
};
E1A99998271A3429008E78C0 /* SwiftUICollection */ = {
isa = XCSwiftPackageProductDependency;
package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */;
productName = SwiftUICollection;
};
E1A9999A271A343C008E78C0 /* SwiftUICollection */ = {
isa = XCSwiftPackageProductDependency;
package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */;
productName = SwiftUICollection;
};
E1AE8E7B2789135A00FBDDAA /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */;

View File

@ -18,6 +18,15 @@
"version" : "0.6.4"
}
},
{
"identity" : "ascollectionview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apptekstudios/ASCollectionView",
"state" : {
"revision" : "4288744ba484c1062c109c0f28d72b629d321d55",
"version" : "2.1.1"
}
},
{
"identity" : "combineext",
"kind" : "remoteSourceControl",
@ -45,6 +54,15 @@
"version" : "6.2.1"
}
},
{
"identity" : "differencekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ra1028/DifferenceKit",
"state" : {
"revision" : "62745d7780deef4a023a792a1f8f763ec7bf9705",
"version" : "1.2.0"
}
},
{
"identity" : "gifu",
"kind" : "remoteSourceControl",
@ -156,10 +174,10 @@
{
"identity" : "swiftuicollection",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ABJC/SwiftUICollection",
"location" : "https://github.com/defagos/SwiftUICollection",
"state" : {
"branch" : "master",
"revision" : "e27149382ce8ec21995069c8aab7ca83d61a3120"
"revision" : "5b9f14eb3ec5d48cec8b3e4462dcc554d4bff2a8"
}
},
{

View File

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.100",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.100",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.250",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.250",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -6,7 +6,9 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
@ -16,7 +18,16 @@ struct LibraryListView: View {
@StateObject
var viewModel = LibraryListViewModel()
let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "other"]
@Default(.Experimental.liveTVAlphaEnabled)
var liveTVAlphaEnabled
var supportedCollectionTypes: [BaseItemDto.ItemType] {
if liveTVAlphaEnabled {
return [.movie, .season, .series, .liveTV, .boxset, .unknown]
} else {
return [.movie, .season, .series, .boxset, .unknown]
}
}
var body: some View {
ScrollView {
@ -46,12 +57,18 @@ struct LibraryListView: View {
if !viewModel.isLoading {
ForEach(viewModel.libraries.filter { [self] library in
let collectionType = library.collectionType ?? "other"
return self.supportedCollectionTypes.contains(collectionType)
let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown
return self.supportedCollectionTypes.contains(itemType)
}, id: \.id) { library in
Button {
libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(parentID: library.id),
title: library.name ?? ""))
let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown
if itemType == .liveTV {
libraryListRouter.route(to: \.liveTV)
} else {
libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(parentID: library.id),
title: library.name ?? ""))
}
} label: {
ZStack {
ImageView(library.getPrimaryImage(maxWidth: 500), blurHash: library.getPrimaryImageBlurHash())

View File

@ -0,0 +1,122 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct LiveTVChannelItemElement: View {
@FocusState
private var focused: Bool
@State
private var loading: Bool = false
@State
private var isFocused: Bool = false
var channel: BaseItemDto
var program: BaseItemDto?
var startString = " "
var endString = " "
var progressPercent = Double(0)
var onSelect: (@escaping (Bool) -> Void) -> Void
private var detailText: String {
guard let program = program else {
return ""
}
var text = ""
if let season = program.parentIndexNumber,
let episode = program.indexNumber
{
text.append("\(season)x\(episode) ")
} else if let episode = program.indexNumber {
text.append("\(episode) ")
}
if let title = program.episodeTitle {
text.append("\(title) ")
}
if let year = program.productionYear {
text.append("\(year) ")
}
if let rating = program.officialRating {
text.append("\(rating)")
}
return text
}
var body: some View {
ZStack {
VStack {
HStack {
Text(channel.number ?? "")
.font(.footnote)
.frame(alignment: .leading)
.padding()
Spacer()
}.frame(alignment: .top)
Spacer()
}
VStack {
ImageView(channel.getPrimaryImage(maxWidth: 128))
.aspectRatio(contentMode: .fit)
.frame(width: 128, alignment: .center)
.padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0))
Text(channel.name ?? "?")
.font(.footnote)
.lineLimit(1)
.frame(alignment: .center)
Text(program?.name ?? L10n.notAvailableSlash)
.font(.body)
.lineLimit(1)
.foregroundColor(.green)
Text(detailText)
.font(.body)
.lineLimit(1)
.foregroundColor(.green)
Spacer()
HStack(alignment: .bottom) {
VStack {
Spacer()
HStack {
Text(startString)
.font(.footnote)
.lineLimit(1)
.frame(alignment: .leading)
Spacer()
Text(endString)
.font(.footnote)
.lineLimit(1)
.frame(alignment: .trailing)
}
GeometryReader { gp in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray)
.opacity(0.4)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
RoundedRectangle(cornerRadius: 6)
.fill(Color.jellyfinPurple)
.frame(width: CGFloat(progressPercent * gp.size.width), height: 12)
}
.frame(alignment: .bottom)
}
}
}
}
.padding()
.opacity(loading ? 0.5 : 1.0)
if loading {
ProgressView()
}
}
.overlay(RoundedRectangle(cornerRadius: 0)
.stroke(Color.blue, lineWidth: 0))
}
}

View File

@ -0,0 +1,151 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct LiveTVChannelItemWideElement: View {
@FocusState
private var focused: Bool
@State
private var loading: Bool = false
@State
private var isFocused: Bool = false
var channel: BaseItemDto
var currentProgram: BaseItemDto?
var currentProgramText: LiveTVChannelViewProgram
var nextProgramsText: [LiveTVChannelViewProgram]
var onSelect: (@escaping (Bool) -> Void) -> Void
var progressPercent: Double {
if let currentProgram = currentProgram {
let progressPercent = currentProgram.getLiveProgressPercentage()
if progressPercent > 1.0 {
return 1.0
} else {
return progressPercent
}
}
return 0
}
private var detailText: String {
guard let program = currentProgram else {
return ""
}
var text = ""
if let season = program.parentIndexNumber,
let episode = program.indexNumber
{
text.append("\(season)x\(episode) ")
} else if let episode = program.indexNumber {
text.append("\(episode) ")
}
if let title = program.episodeTitle {
text.append("\(title) ")
}
if let year = program.productionYear {
text.append("\(year) ")
}
if let rating = program.officialRating {
text.append("\(rating)")
}
return text
}
var body: some View {
ZStack {
ZStack {
HStack {
ZStack(alignment: .center) {
ImageView(channel.getPrimaryImage(maxWidth: 128))
.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))
}
if loading {
ProgressView()
}
}
.aspectRatio(1.0, contentMode: .fit)
VStack(alignment: .leading) {
let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : ""
let channelName = "\(channelNumber)\(channel.name ?? "?")"
Text(channelName)
.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("TextHighlightColor"))
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()
}
Spacer()
}
.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
}
}
}
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color("BackgroundColor"))
.shadow(color: Color("ShadowColor"), radius: 4, x: 0, y: 0)
}
}
@ViewBuilder
func programLabel(timeText: String, titleText: String, color: Color) -> some View {
HStack(alignment: .top) {
Text(timeText)
.font(.footnote)
.lineLimit(2)
.foregroundColor(color)
.frame(width: 38, alignment: .leading)
Text(titleText)
.font(.footnote)
.lineLimit(2)
.foregroundColor(color)
}
}
}

View File

@ -0,0 +1,148 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import ASCollectionView
import Foundation
import JellyfinAPI
import SwiftUI
import SwiftUICollection
typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String)
struct LiveTVChannelsView: View {
@EnvironmentObject
var router: LiveTVCoordinator.Router
@StateObject
var viewModel = LiveTVChannelsViewModel()
@State
private var isPortrait = false
private var columns: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
return 2
} else {
if isPortrait {
return 1
} else {
return 2
}
}
}
var body: some View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.channelPrograms.isEmpty {
ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in
makeCellView(channelProgram)
}
.layout {
.grid(layoutMode: .fixedNumberOfColumns(columns),
itemSpacing: 16,
lineSpacing: 4,
itemSize: .absolute(144))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
.onAppear {
viewModel.startScheduleCheckTimer()
self.checkOrientation()
}
.onDisappear {
viewModel.stopScheduleCheckTimer()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
self.checkOrientation()
}
} else {
VStack {
Text("No results.")
Button {
viewModel.getChannels()
} label: {
Text("Reload")
}
}
}
}
@ViewBuilder
func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View {
let channel = channelProgram.channel
let currentProgramDisplayText = channelProgram.currentProgram?
.programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "")
let nextItems = channelProgram.programs.filter { program in
guard let start = program.startDate else {
return false
}
guard let currentStart = channelProgram.currentProgram?.startDate else {
return false
}
return start > currentStart
}
LiveTVChannelItemWideElement(channel: channel,
currentProgram: channelProgram.currentProgram,
currentProgramText: currentProgramDisplayText,
nextProgramsText: nextProgramsDisplayText(nextItems: nextItems,
timeFormatter: viewModel.timeFormatter),
onSelect: { loadingAction in
loadingAction(true)
self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in
self.router.route(to: \.videoPlayer, playerViewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
loadingAction(false)
}
}
})
}
private func checkOrientation() {
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
guard let scene = windowScene else { return }
self.isPortrait = scene.interfaceOrientation.isPortrait
}
private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] {
var programsDisplayText: [LiveTVChannelViewProgram] = []
for item in nextItems {
programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter))
}
return programsDisplayText
}
}
private extension BaseItemDto {
func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram {
var timeText = ""
if let start = self.startDate {
timeText.append(timeFormatter.string(from: start) + " ")
}
var displayText = ""
if let season = self.parentIndexNumber,
let episode = self.indexNumber
{
displayText.append("\(season)x\(episode) ")
} else if let episode = self.indexNumber {
displayText.append("\(episode) ")
}
if let name = self.name {
displayText.append("\(name) ")
}
if let title = self.episodeTitle {
displayText.append("\(title) ")
}
if let year = self.productionYear {
displayText.append("\(year) ")
}
if let rating = self.officialRating {
displayText.append("\(rating)")
}
return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText)
}
}

View File

@ -10,7 +10,129 @@ import Stinsen
import SwiftUI
struct LiveTVProgramsView: View {
@EnvironmentObject
var programsRouter: LiveTVProgramsCoordinator.Router
@StateObject
var viewModel = LiveTVProgramsViewModel()
var body: some View {
Text("Coming Soon")
ScrollView {
LazyVStack(alignment: .leading) {
if !viewModel.recommendedItems.isEmpty,
let items = viewModel.recommendedItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("On Now")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
if !viewModel.seriesItems.isEmpty,
let items = viewModel.seriesItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("Shows")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
if !viewModel.movieItems.isEmpty,
let items = viewModel.movieItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("Movies")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
if !viewModel.sportsItems.isEmpty,
let items = viewModel.sportsItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("Sports")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
if !viewModel.kidsItems.isEmpty,
let items = viewModel.kidsItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("Kids")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
if !viewModel.newsItems.isEmpty,
let items = viewModel.newsItems
{
PortraitImageHStackView(items: items,
horizontalAlignment: .leading) {
Text("News")
.font(.headline)
.fontWeight(.semibold)
.padding(.leading, 90)
} selectedAction: { item in
if let chanId = item.channelId,
let chan = viewModel.findChannel(id: chanId)
{
self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in
self.programsRouter.route(to: \.videoPlayer, playerViewModel)
}
}
}
}
}
}
}
}

View File

@ -17,6 +17,12 @@ struct ExperimentalSettingsView: View {
var syncSubtitleStateWithAdjacent
@Default(.Experimental.nativePlayer)
var nativePlayer
@Default(.Experimental.liveTVAlphaEnabled)
var liveTVAlphaEnabled
@Default(.Experimental.liveTVForceDirectPlay)
var liveTVForceDirectPlay
@Default(.Experimental.liveTVNativePlayer)
var liveTVNativePlayer
var body: some View {
Form {
@ -31,6 +37,18 @@ struct ExperimentalSettingsView: View {
} header: {
L10n.experimental.text
}
Section {
Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled)
Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay)
Toggle("Live TV Native Player", isOn: $liveTVNativePlayer)
} header: {
Text("Live TV")
}
}
}
}

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) 2022 Jellyfin & Jellyfin Contributors
//
import AVKit
import Combine
import JellyfinAPI
import UIKit
class LiveTVNativePlayerViewController: AVPlayerViewController {
let viewModel: VideoPlayerViewModel
var timeObserverToken: Any?
var lastProgressTicks: Int64 = 0
private var cancellables = Set<AnyCancellable>()
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
let player: AVPlayer
if let transcodedStreamURL = viewModel.transcodedStreamURL {
player = AVPlayer(url: transcodedStreamURL)
} else {
player = AVPlayer(url: viewModel.hlsStreamURL)
}
player.appliesMediaSelectionCriteriaAutomatically = false
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
if time.seconds != 0 {
self?.sendProgressReport(seconds: time.seconds)
}
}
self.player = player
self.allowsPictureInPicturePlayback = true
self.player?.allowsExternalPlayback = true
}
private func createMetadataItem(for identifier: AVMetadataIdentifier,
value: Any) -> AVMetadataItem
{
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
}
@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()
removePeriodicTimeObserver()
}
func removePeriodicTimeObserver() {
if let timeObserverToken = timeObserverToken {
player?.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000),
toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1),
completionHandler: { _ in
self.play()
})
}
private func play() {
player?.play()
viewModel.sendPlayReport()
}
private func sendProgressReport(seconds: Double) {
viewModel.setSeconds(Int64(seconds))
viewModel.sendProgressReport()
}
private func stop() {
self.player?.pause()
viewModel.sendStopReport()
}
}

View File

@ -0,0 +1,38 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import SwiftUI
import UIKit
struct LiveTVNativePlayerView: UIViewControllerRepresentable {
let viewModel: VideoPlayerViewModel
typealias UIViewControllerType = LiveTVNativePlayerViewController
func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController {
LiveTVNativePlayerViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {}
}
struct LiveTVPlayerView: UIViewControllerRepresentable {
let viewModel: VideoPlayerViewModel
typealias UIViewControllerType = LiveTVPlayerViewController
func makeUIViewController(context: Context) -> LiveTVPlayerViewController {
LiveTVPlayerViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {}
}

File diff suppressed because it is too large Load Diff