From 7dab722cda787c44e0909064bb9d93555477cf6e Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 18 Nov 2021 08:09:53 -0600 Subject: [PATCH] Add live TV channels grid. Remove guide view for now. --- .../Components/LandscapeItemElement.swift | 2 +- .../Components/LiveTVChannelItemElement.swift | 98 +++++++++ .../Views/LiveTVChannelsView.swift | 80 +++++++- ...TVGuideView.swift => LiveTVHomeView.swift} | 11 +- JellyfinPlayer.xcodeproj/project.pbxproj | 26 +-- .../Coordinators/LiveTVGuideCoordinator.swift | 30 --- .../Coordinators/LiveTVTabCoordinator.swift | 30 +-- .../ViewModels/LiveTVChannelsViewModel.swift | 187 ++++++++++++++++++ Shared/ViewModels/LiveTVGuideViewModel.swift | 15 -- 9 files changed, 394 insertions(+), 85 deletions(-) create mode 100644 JellyfinPlayer tvOS/Components/LiveTVChannelItemElement.swift rename JellyfinPlayer tvOS/Views/{LiveTVGuideView.swift => LiveTVHomeView.swift} (64%) delete mode 100644 Shared/Coordinators/LiveTVGuideCoordinator.swift delete mode 100644 Shared/ViewModels/LiveTVGuideViewModel.swift diff --git a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift index 23b07c1d..eb6b6a01 100644 --- a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift +++ b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift @@ -10,7 +10,7 @@ import SwiftUI import JellyfinAPI -private struct CutOffShadow: Shape { +struct CutOffShadow: Shape { func path(in rect: CGRect) -> Path { var path = Path() diff --git a/JellyfinPlayer tvOS/Components/LiveTVChannelItemElement.swift b/JellyfinPlayer tvOS/Components/LiveTVChannelItemElement.swift new file mode 100644 index 00000000..0e2037a9 --- /dev/null +++ b/JellyfinPlayer tvOS/Components/LiveTVChannelItemElement.swift @@ -0,0 +1,98 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + + +import SwiftUI +import JellyfinAPI + +struct LiveTVChannelItemElement: View { + @Environment(\.isFocused) var envFocused: Bool + @State var focused: Bool = false + + var channel: BaseItemDto + var program: BaseItemDto? + + var dateFormatter: DateFormatter { + let df = DateFormatter() + df.dateFormat = "h:mm" + return df + } + + var body: some View { + VStack { + HStack { + Spacer() + Text(channel.number ?? "") + .font(.footnote) + .frame(alignment: .trailing) + }.frame(alignment: .top) + ImageView(src: channel.getPrimaryImage(maxWidth: 125)) + .frame(width: 125, alignment: .center) + .offset(x: 0, y: -32) + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + if let currentProgram = program { + Text(currentProgram.name ?? "") + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + } + + if let currentProgram = program, + let start = currentProgram.startDate?.toLocalTime().timeIntervalSinceReferenceDate, + let end = currentProgram.endDate?.toLocalTime().timeIntervalSinceReferenceDate { + let now = Date().timeIntervalSinceReferenceDate + let length = end - start + let progress = now - start + let progPercent = progress / length + + VStack { + if let startDate = currentProgram.startDate, + let endDate = currentProgram.endDate { + HStack { + Text(dateFormatter.string(from: startDate.toLocalTime())) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .leading) + + Spacer() + + Text(dateFormatter.string(from: endDate.toLocalTime())) + .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(red: 172/255, green: 92/255, blue: 195/255)) + .frame(width: CGFloat(progPercent * gp.size.width), height: 12) + } + } + } + } + } + .padding() + .background(Color.clear) + .border(focused ? Color.blue : Color.clear, width: 4) + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .scaleEffect(focused ? 1.1 : 1) + } +} diff --git a/JellyfinPlayer tvOS/Views/LiveTVChannelsView.swift b/JellyfinPlayer tvOS/Views/LiveTVChannelsView.swift index 71774eeb..8e2fd05d 100644 --- a/JellyfinPlayer tvOS/Views/LiveTVChannelsView.swift +++ b/JellyfinPlayer tvOS/Views/LiveTVChannelsView.swift @@ -9,14 +9,88 @@ import Foundation import SwiftUI +import SwiftUICollection + struct LiveTVChannelsView: View { @EnvironmentObject var programsRouter: LiveTVChannelsCoordinator.Router @StateObject var viewModel = LiveTVChannelsViewModel() - + var body: some View { - Button {} label: { - Text("Coming Soon") + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { section, env in + return createGridLayout() + } cell: { indexPath, cell in + makeCellView(indexPath: indexPath, cell: cell) + } supplementaryView: { _, indexPath in + EmptyView() + .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + } else { + VStack { + Text("No results.") + Button { + print("movieLibraries reload") + } label: { + Text("Reload") + } + } } } + + @ViewBuilder func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { + GeometryReader { _ in + if let item = cell.item, + let channel = item.channel{ + if channel.type != "Folder" { + Button { + } label: { + LiveTVChannelItemElement(channel: channel, program: item.program) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } + } + } + + private func createGridLayout() -> NSCollectionLayoutSection { + // I don't know why tvOS has a margin on the sides of a collection view + // But it does, even with contentInset = .zero and ignoreSafeArea. + let sideMargin = CGFloat(30) + let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = .init( + leading: .fixed(8), + top: .fixed(8), + trailing: .fixed(8), + bottom: .fixed(8) + ) + + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(itemWidth)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) + group.edgeSpacing = .init( + leading: .fixed(0), + top: .fixed(16), + trailing: .fixed(0), + bottom: .fixed(16) + ) + group.contentInsets = .zero + + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero + + return section + } + } diff --git a/JellyfinPlayer tvOS/Views/LiveTVGuideView.swift b/JellyfinPlayer tvOS/Views/LiveTVHomeView.swift similarity index 64% rename from JellyfinPlayer tvOS/Views/LiveTVGuideView.swift rename to JellyfinPlayer tvOS/Views/LiveTVHomeView.swift index d7d2b56d..691dbc27 100644 --- a/JellyfinPlayer tvOS/Views/LiveTVGuideView.swift +++ b/JellyfinPlayer tvOS/Views/LiveTVHomeView.swift @@ -10,13 +10,14 @@ import Foundation import SwiftUI -struct LiveTVGuideView: View { - @EnvironmentObject var programsRouter: LiveTVGuideCoordinator.Router - @StateObject var viewModel = LiveTVGuideViewModel() - +struct LiveTVHomeView: View { + @EnvironmentObject var mainCoordinator: MainCoordinator.Router + var body: some View { Button {} label: { - Text("Coming Soon") + Text("Return Home") + }.onAppear { + self.mainCoordinator.root(\.mainTab) } } } diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index ccbca006..ec581680 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -248,19 +248,16 @@ C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */; }; C4BE07792726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */; }; C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */; }; - C4BE077C272837C8003F4AD1 /* LiveTVGuideViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE077B272837C8003F4AD1 /* LiveTVGuideViewModel.swift */; }; - C4BE077D272837C8003F4AD1 /* LiveTVGuideViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE077B272837C8003F4AD1 /* LiveTVGuideViewModel.swift */; }; - C4BE0780272837FB003F4AD1 /* LiveTVGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE077E272837FB003F4AD1 /* LiveTVGuideView.swift */; }; - C4BE07822728383F003F4AD1 /* LiveTVGuideCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07812728383F003F4AD1 /* LiveTVGuideCoordinator.swift */; }; - C4BE07832728383F003F4AD1 /* LiveTVGuideCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07812728383F003F4AD1 /* LiveTVGuideCoordinator.swift */; }; C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; }; C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; }; C4BE07882728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C4BE078B272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; + C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; }; 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 */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; @@ -583,14 +580,15 @@ C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsViewModel.swift; sourceTree = ""; }; C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVTabCoordinator.swift; sourceTree = ""; }; - C4BE077B272837C8003F4AD1 /* LiveTVGuideViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVGuideViewModel.swift; sourceTree = ""; }; - C4BE077E272837FB003F4AD1 /* LiveTVGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVGuideView.swift; sourceTree = ""; }; - C4BE07812728383F003F4AD1 /* LiveTVGuideCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVGuideCoordinator.swift; sourceTree = ""; }; C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsViewModel.swift; sourceTree = ""; }; C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsCoordinator.swift; sourceTree = ""; }; C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; + C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; + C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; + D79953919FED0C4DF72BA578 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; + DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; @@ -746,7 +744,6 @@ 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, - C4BE077B272837C8003F4AD1 /* LiveTVGuideViewModel.swift */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, @@ -872,6 +869,7 @@ isa = PBXGroup; children = ( 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, + C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */, E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */, 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */, 53116A18268B947A003024C9 /* PlainLinkButton.swift */, @@ -1136,7 +1134,6 @@ 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */, - C4BE07812728383F003F4AD1 /* LiveTVGuideCoordinator.swift */, C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */, 62C29EA726D103D500C1D2E7 /* LibraryListCoordinator.swift */, @@ -1215,8 +1212,8 @@ C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */, 53A83C32268A309300DF3D92 /* LibraryView.swift */, C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, - C4BE077E272837FB003F4AD1 /* LiveTVGuideView.swift */, C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */, + C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */, C4BE0768271FC164003F4AD1 /* TVLibrariesView.swift */, 531690EE267ABF72005D8AB9 /* NextUpView.swift */, @@ -1823,16 +1820,15 @@ 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, - C4BE0780272837FB003F4AD1 /* LiveTVGuideView.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */, + C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, - C4BE07832728383F003F4AD1 /* LiveTVGuideCoordinator.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, @@ -1862,6 +1858,7 @@ E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, @@ -1916,7 +1913,6 @@ 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, - C4BE077D272837C8003F4AD1 /* LiveTVGuideViewModel.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, @@ -1981,7 +1977,6 @@ 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, - C4BE077C272837C8003F4AD1 /* LiveTVGuideViewModel.swift in Sources */, 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, @@ -1997,7 +1992,6 @@ 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, - C4BE07822728383F003F4AD1 /* LiveTVGuideCoordinator.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, diff --git a/Shared/Coordinators/LiveTVGuideCoordinator.swift b/Shared/Coordinators/LiveTVGuideCoordinator.swift deleted file mode 100644 index 7e176f7c..00000000 --- a/Shared/Coordinators/LiveTVGuideCoordinator.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -final class LiveTVGuideCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \LiveTVGuideCoordinator.start) - - @Root var start = makeStart - @Route(.modal) var modalItem = makeModalItem - - func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { - return NavigationViewCoordinator(ItemCoordinator(item: item)) - } - - @ViewBuilder - func makeStart() -> some View { - LiveTVGuideView() - } -} diff --git a/Shared/Coordinators/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift index 971c83b5..6d0d28d8 100644 --- a/Shared/Coordinators/LiveTVTabCoordinator.swift +++ b/Shared/Coordinators/LiveTVTabCoordinator.swift @@ -13,14 +13,14 @@ import Stinsen final class LiveTVTabCoordinator: TabCoordinatable { var child = TabChild(startingItems: [ - \LiveTVTabCoordinator.programs, - \LiveTVTabCoordinator.guide, - \LiveTVTabCoordinator.channels + \LiveTVTabCoordinator.programs, + \LiveTVTabCoordinator.channels, + \LiveTVTabCoordinator.home ]) @Route(tabItem: makeProgramsTab) var programs = makePrograms - @Route(tabItem: makeGuideTab) var guide = makeGuide @Route(tabItem: makeChannelsTab) var channels = makeChannels + @Route(tabItem: makeHomeTab) var home = makeHome func makePrograms() -> NavigationViewCoordinator { return NavigationViewCoordinator(LiveTVProgramsCoordinator()) @@ -33,17 +33,6 @@ final class LiveTVTabCoordinator: TabCoordinatable { } } - func makeGuide() -> NavigationViewCoordinator { - return NavigationViewCoordinator(LiveTVGuideCoordinator()) - } - - @ViewBuilder func makeGuideTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "calendar") - Text("Guide") - } - } - func makeChannels() -> NavigationViewCoordinator { return NavigationViewCoordinator(LiveTVChannelsCoordinator()) } @@ -54,4 +43,15 @@ final class LiveTVTabCoordinator: TabCoordinatable { Text("Channels") } } + + func makeHome() -> LiveTVHomeView { + return LiveTVHomeView() + } + + @ViewBuilder func makeHomeTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "house") + Text("Home") + } + } } diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index ce29d882..68922adb 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -9,7 +9,194 @@ import Foundation import JellyfinAPI +import SwiftUICollection + +typealias LiveTVChannelRow = CollectionRow + +struct LiveTVChannelRowCell: Hashable { + let id = UUID() + let item: LiveTVChannelProgram +} + +struct LiveTVChannelProgram: Hashable { + let id = UUID() + let channel: BaseItemDto + let program: BaseItemDto? +} final class LiveTVChannelsViewModel: ViewModel { + @Published var channels = [BaseItemDto]() + @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]]() + + override init() { + super.init() + + getChannels() + } + + private func getGuideInfo() { + LiveTvAPI.getGuideInfo() + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.log.debug("Received Guide Info") + guard let self = self else { return } + self.getChannels() + }) + .store(in: &cancellables) + } + + private func getChannels() { + LiveTvAPI.getLiveTvChannels( + userId: SessionManager.main.currentLogin.user.id, + startIndex: 0, + limit: 500, + enableImageTypes: [.primary], + enableUserData: false, + enableFavoriteSorting: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.log.debug("Received \(response.items?.count ?? 0) Channels") + guard let self = self else { return } + self.channels = response.items ?? [] + self.getPrograms() + }) + .store(in: &cancellables) + } + + private func getPrograms() { + // http://192.168.1.50:8096/LiveTv/Programs + guard channels.count > 0 else { + LogManager.shared.log.debug("Cannot get programs, channels list empty. ") + return + } + let channelIds = channels.compactMap { $0.id } + + let minEndDate = Date.now.addComponentsToDate(hours: -1) + let maxStartDate = minEndDate.addComponentsToDate(days: 1) + + NSLog("*** maxStartDate: \(maxStartDate)") + NSLog("*** minEndDate: \(minEndDate)") + + let getProgramsDto = GetProgramsDto( + channelIds: channelIds, + userId: SessionManager.main.currentLogin.user.id, + maxStartDate: maxStartDate, + minEndDate: minEndDate, + sortBy: ["StartDate"], + enableImages: true, + enableTotalRecordCount: false, + imageTypeLimit: 1, + enableImageTypes: [.primary], + enableUserData: false + ) + + LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.log.debug("Received \(response.items?.count ?? 0) Programs") + guard let self = self else { return } + self.programs = response.items ?? [] + self.channelPrograms = self.processChannelPrograms() + }) + .store(in: &cancellables) + } + + private func processChannelPrograms() -> [LiveTVChannelProgram] { + var channelPrograms = [LiveTVChannelProgram]() + let now = Date() + let df = DateFormatter() + df.dateFormat = "MM/dd h:mm ZZZ" + for channel in self.channels { + // NSLog("\n\(channel.name)") + let prgs = self.programs.filter { item in + item.channelId == channel.id + } + channelProgramsList[channel] = prgs + + var currentPrg: BaseItemDto? + for prg in prgs { + var startString = "" + var endString = "" + if let start = prg.startDate?.toLocalTime() { + startString = df.string(from: start) + } + if let end = prg.endDate?.toLocalTime() { + endString = df.string(from: end) + } + //NSLog("\(prg.name) - \(startString) to \(endString)") + if let startDate = prg.startDate?.toLocalTime() , + let endDate = prg.endDate?.toLocalTime(), + now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && + now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate { + currentPrg = prg + } + } + + channelPrograms.append(LiveTVChannelProgram(channel: channel, program: currentPrg)) + } + return channelPrograms + } +} + +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} + +extension Date { + func addComponentsToDate(seconds sec: Int? = nil, minutes min: Int? = nil, hours hrs: Int? = nil, days d: Int? = nil) -> Date { + var dc = DateComponents() + if let sec = sec { + dc.second = sec + } + if let min = min { + dc.minute = min + } + if let hrs = hrs { + dc.hour = hrs + } + if let d = d { + dc.day = d + } + return Calendar.current.date(byAdding: dc, to: self)! + } + + func midnightUTCDate() -> Date { + var dc: DateComponents = Calendar.current.dateComponents([.year, .month, .day], from: self) + dc.hour = 0 + dc.minute = 0 + dc.second = 0 + dc.nanosecond = 0 + dc.timeZone = TimeZone(secondsFromGMT: 0) + return Calendar.current.date(from: dc)! + } + + func toLocalTime() -> Date { + let timezoneOffset = TimeZone.current.secondsFromGMT() + let epochDate = self.timeIntervalSince1970 + let timezoneEpochOffset = (epochDate + Double(timezoneOffset)) + return Date(timeIntervalSince1970: timezoneEpochOffset) + } } diff --git a/Shared/ViewModels/LiveTVGuideViewModel.swift b/Shared/ViewModels/LiveTVGuideViewModel.swift deleted file mode 100644 index 4a2fa5ff..00000000 --- a/Shared/ViewModels/LiveTVGuideViewModel.swift +++ /dev/null @@ -1,15 +0,0 @@ -// - /* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import JellyfinAPI - -final class LiveTVGuideViewModel: ViewModel { - -}