diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index e425af55..8bddf1c0 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -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 } diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index 9c300ccd..f36aa1bf 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -120,9 +120,7 @@ struct LiveTVChannelItemElement: View { .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)) .cornerRadius(20) .scaleEffect(isFocused ? 1.1 : 1) -#if os(tvOS) .focusable(true) -#endif .focused($focused) .onChange(of: focused) { foc in withAnimation(.linear(duration: 0.15)) { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 26029f37..f5c5fdc8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -252,9 +252,13 @@ 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 */; }; 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 */; }; @@ -264,8 +268,6 @@ 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 */; }; - C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; - C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.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 */; }; @@ -288,7 +290,7 @@ 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 */; }; + 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 */; }; @@ -740,6 +742,8 @@ 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; + C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = ""; }; C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesLibrariesCoordinator.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = ""; }; C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = ""; }; @@ -769,6 +773,7 @@ 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 = ""; }; + C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; @@ -1636,6 +1641,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 */, @@ -2077,6 +2085,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 */, @@ -2251,6 +2260,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 */, @@ -2276,7 +2286,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 */, @@ -2387,6 +2396,7 @@ 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 */, @@ -2425,6 +2435,7 @@ 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 */, @@ -2490,6 +2501,7 @@ 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 */, @@ -2518,12 +2530,10 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, - C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, - C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..5d336734 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -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 + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift new file mode 100644 index 00000000..0ff8dc06 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -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)) + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift new file mode 100644 index 00000000..6dba9684 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -0,0 +1,152 @@ +// +// 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 { + HStack { + ZStack(alignment: .bottomLeading) { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) + VStack { + 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: .bottomLeading) + .padding(.init(top: 0, leading: 0, bottom: 0, trailing: 8)) + } + } + .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) + .frame(alignment: .leading) + HStack(alignment: .top) { + Text(currentProgramText.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.green) + .frame(width: 40) + Text(currentProgramText.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.green) + } + if nextProgramsText.count > 0, + let nextItem = nextProgramsText[0] { + HStack(alignment: .top) { + Text(nextItem.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + .frame(width: 40) + Text(nextItem.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + } + } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] { + HStack(alignment: .top) { + Text(nextItem2.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + .frame(width: 40) + Text(nextItem2.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + } + } + Spacer() + } + Spacer() + } + .frame(alignment: .leading) + .padding() + .opacity(loading ? 0.5 : 1.0) + + if loading { + ProgressView() + } + } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color("BackgroundColor")) + ) + .frame(height: 128) + } +} diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift new file mode 100644 index 00000000..c6102e42 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -0,0 +1,210 @@ +// +// 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 SwiftUI +import SwiftUICollection + +typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) + +struct LiveTVChannelsView: View { + @EnvironmentObject + var router: LiveTVChannelsCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() + @State private var isPortrait = false + + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + 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() + .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(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { + let item = cell.item + let channel = item.channel + 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 + } + LiveTVChannelItemWideElement(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 { + if UIDevice.current.userInterfaceIdiom == .pad { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 2), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item, item2] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + if isPortrait { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(UIScreen.main.bounds.width - 2), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.49), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item, item2] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } + } + } + + 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 + print("orientationDidChange: isPortrait? \(self.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) + } +}