diff --git a/Shared/Components/AlternateLayoutView.swift b/Shared/Components/AlternateLayoutView.swift new file mode 100644 index 00000000..6f0e2608 --- /dev/null +++ b/Shared/Components/AlternateLayoutView.swift @@ -0,0 +1,35 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +/// A view that takes a view to affect layout while overlaying the content. +struct AlternateLayoutView: View { + + private let alignment: Alignment + private let content: () -> Content + private let layout: () -> Layout + + init( + alignment: Alignment = .center, + @ViewBuilder layout: @escaping () -> Layout, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.content = content + self.layout = layout + } + + var body: some View { + layout() + .hidden() + .overlay(alignment: alignment) { + content() + } + } +} diff --git a/Shared/Components/PlainNavigationLinkButton.swift b/Shared/Components/PlainNavigationLinkButton.swift deleted file mode 100644 index b70c3c28..00000000 --- a/Shared/Components/PlainNavigationLinkButton.swift +++ /dev/null @@ -1,23 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct PlainNavigationLinkButtonStyle: ButtonStyle { - func makeBody(configuration: Self.Configuration) -> some View { - PlainNavigationLinkButton(configuration: configuration) - } -} - -struct PlainNavigationLinkButton: View { - let configuration: ButtonStyle.Configuration - - var body: some View { - configuration.label - } -} diff --git a/Shared/Components/ProgressBar.swift b/Shared/Components/ProgressBar.swift index 2ee61da3..3eaf5c2d 100644 --- a/Shared/Components/ProgressBar.swift +++ b/Shared/Components/ProgressBar.swift @@ -8,8 +8,7 @@ import SwiftUI -// TODO: Replace scaling with size so that the Capsule corner radius -// is not affected +// TODO: see if animation is correct here or should be in caller views struct ProgressBar: View { @@ -18,12 +17,15 @@ struct ProgressBar: View { var body: some View { ZStack(alignment: .leading) { Capsule() - .foregroundColor(.white) + .foregroundColor(.secondary) .opacity(0.2) Capsule() - .scaleEffect(x: progress, y: 1, anchor: .leading) + .mask(alignment: .leading) { + Rectangle() + .scaleEffect(x: progress, anchor: .leading) + } } - .frame(maxWidth: .infinity) + .animation(.linear(duration: 0.1), value: progress) } } diff --git a/Shared/Components/SystemImageContentView.swift b/Shared/Components/SystemImageContentView.swift index a6351657..001d9023 100644 --- a/Shared/Components/SystemImageContentView.swift +++ b/Shared/Components/SystemImageContentView.swift @@ -8,20 +8,28 @@ import SwiftUI +// TODO: is the background color setting really the best way? + struct SystemImageContentView: View { @State private var contentSize: CGSize = .zero + private var backgroundColor: Color + private var heightRatio: CGFloat private let systemName: String + private var widthRatio: CGFloat init(systemName: String?) { + self.backgroundColor = Color.secondarySystemFill + self.heightRatio = 3 self.systemName = systemName ?? "circle" + self.widthRatio = 3.5 } var body: some View { ZStack { - Color.secondarySystemFill + backgroundColor .opacity(0.5) Image(systemName: systemName) @@ -29,8 +37,20 @@ struct SystemImageContentView: View { .aspectRatio(contentMode: .fit) .foregroundColor(.secondary) .accessibilityHidden(true) - .frame(width: contentSize.width / 3.5, height: contentSize.height / 3) + .frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio) } .size($contentSize) } } + +extension SystemImageContentView { + + func background(color: Color = Color.secondarySystemFill) -> Self { + copy(modifying: \.backgroundColor, with: color) + } + + func imageFrameRatio(width: CGFloat = 3.5, height: CGFloat = 3) -> Self { + copy(modifying: \.heightRatio, with: height) + .copy(modifying: \.widthRatio, with: width) + } +} diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator/iOSLiveTVCoordinator.swift similarity index 77% rename from Shared/Coordinators/LiveTVCoordinator.swift rename to Shared/Coordinators/LiveTVCoordinator/iOSLiveTVCoordinator.swift index 935ba89b..12ccd6fe 100644 --- a/Shared/Coordinators/LiveTVCoordinator.swift +++ b/Shared/Coordinators/LiveTVCoordinator/iOSLiveTVCoordinator.swift @@ -18,8 +18,15 @@ final class LiveTVCoordinator: NavigationCoordinatable { @Root var start = makeStart + @Route(.push) + var channels = makeChannels + + func makeChannels() -> ChannelLibraryView { + ChannelLibraryView() + } + @ViewBuilder func makeStart() -> some View { - LiveTVChannelsView() + ProgramsView() } } diff --git a/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift new file mode 100644 index 00000000..1e091649 --- /dev/null +++ b/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift @@ -0,0 +1,46 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import Stinsen +import SwiftUI + +final class LiveTVCoordinator: TabCoordinatable { + + var child = TabChild(startingItems: [ + \LiveTVCoordinator.programs, + \LiveTVCoordinator.channels, + ]) + + @Route(tabItem: makeProgramsTab) + var programs = makePrograms + @Route(tabItem: makeChannelsTab) + var channels = makeChannels + + func makePrograms() -> VideoPlayerWrapperCoordinator { + VideoPlayerWrapperCoordinator { + ProgramsView() + } + } + + @ViewBuilder + func makeProgramsTab(isActive: Bool) -> some View { + Label(L10n.programs, systemImage: "tv") + } + + func makeChannels() -> VideoPlayerWrapperCoordinator { + VideoPlayerWrapperCoordinator { + ChannelLibraryView() + } + } + + @ViewBuilder + func makeChannelsTab(isActive: Bool) -> some View { + Label(L10n.channels, systemImage: "play.square.stack") + } +} diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift deleted file mode 100644 index b6a89314..00000000 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ /dev/null @@ -1,53 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Algorithms -import Defaults -import Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -final class LiveTVProgramsCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) - - @Root - var start = makeStart - - #if os(tvOS) - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer - #endif - - #if os(tvOS) - func makeVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager)) - } - #endif - -// @ViewBuilder - func makeStart() -> some View { - let viewModel = LiveTVProgramsViewModel() - -// let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() } -// -// for channel in channels { -// viewModel.channels[channel.id!] = channel -// } -// -// viewModel.recommendedItems = channels.randomSample(count: 5) -// viewModel.seriesItems = channels.randomSample(count: 5) -// viewModel.movieItems = channels.randomSample(count: 5) -// viewModel.sportsItems = channels.randomSample(count: 5) -// viewModel.kidsItems = channels.randomSample(count: 5) -// viewModel.newsItems = channels.randomSample(count: 5) - - return LiveTVProgramsView(viewModel: viewModel) - } -} diff --git a/Shared/Coordinators/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift deleted file mode 100644 index 58981f0a..00000000 --- a/Shared/Coordinators/LiveTVTabCoordinator.swift +++ /dev/null @@ -1,63 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation -import Stinsen -import SwiftUI - -final class LiveTVTabCoordinator: TabCoordinatable { - - var child = TabChild(startingItems: [ - \LiveTVTabCoordinator.channels, - \LiveTVTabCoordinator.programs, - \LiveTVTabCoordinator.home, - ]) - - @Route(tabItem: makeChannelsTab) - var channels = makeChannels - @Route(tabItem: makeProgramsTab) - var programs = makePrograms - @Route(tabItem: makeHomeTab) - var home = makeHome - - func makeChannels() -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVChannelsCoordinator()) - } - - @ViewBuilder - func makeChannelsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "square.grid.3x3") - L10n.channels.text - } - } - - func makePrograms() -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVProgramsCoordinator()) - } - - @ViewBuilder - func makeProgramsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - L10n.programs.text - } - } - - func makeHome() -> LiveTVHomeView { - LiveTVHomeView() - } - - @ViewBuilder - func makeHomeTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "house") - L10n.home.text - } - } -} diff --git a/Shared/Coordinators/LiveVideoPlayerCoordinator.swift b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift index 8915fa40..0c2e1263 100644 --- a/Shared/Coordinators/LiveVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift @@ -38,18 +38,20 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable { LiveNativeVideoPlayer(manager: self.videoPlayerManager) } } + .preferredColorScheme(.dark) + .supportedOrientations(UIDevice.isPhone ? .landscape : .allButUpsideDown) } .ignoresSafeArea() + .backport + .persistentSystemOverlays(.hidden) #else PreferencesView { - Group { - if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - LiveVideoPlayer(manager: self.videoPlayerManager) - } else { - LiveNativeVideoPlayer(manager: self.videoPlayerManager) - } + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + LiveVideoPlayer(manager: self.videoPlayerManager) + } else { + LiveNativeVideoPlayer(manager: self.videoPlayerManager) } } .ignoresSafeArea() diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 5aae4b33..2dc441b8 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -23,10 +23,6 @@ final class MainCoordinator: NavigationCoordinatable { var mainTab = makeMainTab @Root var serverList = makeServerList - @Root - var liveTV = makeLiveTV -// @Route(.fullScreen) -// var videoPlayer = makeVideoPlayer init() { @@ -65,12 +61,4 @@ final class MainCoordinator: NavigationCoordinatable { func makeServerList() -> NavigationViewCoordinator { NavigationViewCoordinator(ServerListCoordinator()) } - - func makeLiveTV() -> LiveTVTabCoordinator { - LiveTVTabCoordinator() - } - -// func makeVideoPlayer(parameters: VideoPlayerCoordinator.Parameters) -> NavigationViewCoordinator { -// NavigationViewCoordinator(VideoPlayerCoordinator(parameters: parameters)) -// } } diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index d85218bd..0009e67f 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -12,6 +12,7 @@ import Stinsen import SwiftUI final class MainTabCoordinator: TabCoordinatable { + var child = TabChild(startingItems: [ \MainTabCoordinator.home, \MainTabCoordinator.tvShows, diff --git a/Shared/Coordinators/MediaCoordinator.swift b/Shared/Coordinators/MediaCoordinator.swift index 53adec6a..dcf178a0 100644 --- a/Shared/Coordinators/MediaCoordinator.swift +++ b/Shared/Coordinators/MediaCoordinator.swift @@ -20,6 +20,8 @@ final class MediaCoordinator: NavigationCoordinatable { #if os(tvOS) @Route(.modal) var library = makeLibrary + @Route(.modal) + var liveTV = makeLiveTV #else @Route(.push) var library = makeLibrary @@ -33,21 +35,20 @@ final class MediaCoordinator: NavigationCoordinatable { func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } - #else func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { LibraryCoordinator(viewModel: viewModel) } - func makeLiveTV() -> LiveTVCoordinator { - LiveTVCoordinator() - } - func makeDownloads() -> DownloadListCoordinator { DownloadListCoordinator() } #endif + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + @ViewBuilder func makeStart() -> some View { MediaView() diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift index 513b30e0..d56b2abd 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator.swift @@ -49,8 +49,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .ignoresSafeArea() .backport .persistentSystemOverlays(.hidden) - .backport - .defersSystemGestures(on: .all) #else if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/VideoPlayerWrapperCoordinator.swift similarity index 51% rename from Shared/Coordinators/LiveTVChannelsCoordinator.swift rename to Shared/Coordinators/VideoPlayerWrapperCoordinator.swift index ad836c92..4ca46fe7 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerWrapperCoordinator.swift @@ -6,32 +6,36 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Defaults -import Foundation -import JellyfinAPI import Stinsen import SwiftUI -final class LiveTVChannelsCoordinator: NavigationCoordinatable { +// TODO: add normal video player +// TODO: replace current instances of video player on other coordinators, if able - let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start) +/// A coordinator used on tvOS to present video players due to differences in view controller presentation. +final class VideoPlayerWrapperCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \VideoPlayerWrapperCoordinator.start) @Root var start = makeStart - #if os(tvOS) @Route(.fullScreen) var liveVideoPlayer = makeLiveVideoPlayer - #endif - #if os(tvOS) + private let content: () -> any View + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } + func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator { NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager)) } - #endif @ViewBuilder - func makeStart() -> some View { - LiveTVChannelsView() + private func makeStart() -> some View { + content() + .eraseToAnyView() } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index e89c8100..97b34345 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -46,14 +46,16 @@ extension BaseItemDto: Poster { var typeSystemImage: String? { switch type { + case .boxSet: + "film.stack" case .episode, .movie, .series: "film" case .folder: "folder.fill" case .person: "person.fill" - case .boxSet: - "film.stack" + case .program: + "tv" default: nil } } @@ -83,6 +85,8 @@ extension BaseItemDto: Poster { } case .folder: return [imageSource(.primary, maxWidth: maxWidth)] + case .program: + return [imageSource(.primary, maxWidth: maxWidth)] case .video: return [imageSource(.primary, maxWidth: maxWidth)] default: diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index d3cca7c3..bdced5d3 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -12,6 +12,8 @@ import Foundation import JellyfinAPI import UIKit +// TODO: clean up + extension BaseItemDto: Displayable { var displayTitle: String { @@ -97,18 +99,27 @@ extension BaseItemDto { return " " } - func getLiveProgressPercentage() -> Double { - if let startDate, - let endDate - { - let start = startDate.timeIntervalSinceReferenceDate - let end = endDate.timeIntervalSinceReferenceDate - let now = Date().timeIntervalSinceReferenceDate - let length = end - start - let progress = now - start - return progress / length - } - return 0 + var programDuration: TimeInterval? { + guard let startDate, let endDate else { return nil } + return endDate.timeIntervalSince(startDate) + } + + var programProgress: Double? { + guard let startDate, let endDate else { return nil } + + let length = endDate.timeIntervalSince(startDate) + let progress = Date.now.timeIntervalSince(startDate) + + return progress / length + } + + func programProgress(relativeTo other: Date) -> Double? { + guard let startDate, let endDate else { return nil } + + let length = endDate.timeIntervalSince(startDate) + let progress = other.timeIntervalSince(startDate) + + return progress / length } var subtitleStreams: [MediaStream] { diff --git a/Shared/Extensions/Sequence.swift b/Shared/Extensions/Sequence.swift index c90c2176..0e99c9f7 100644 --- a/Shared/Extensions/Sequence.swift +++ b/Shared/Extensions/Sequence.swift @@ -24,6 +24,27 @@ extension Sequence { sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) } + /// Returns the elements of the sequence, sorted by comparing values + /// at the given `KeyPath` of `Element`. + /// + /// `nil` values are considered the maximum. + func sorted(using keyPath: KeyPath) -> [Element] { + sorted { + let x = $0[keyPath: keyPath] + let y = $1[keyPath: keyPath] + + if let x, let y { + return x < y + } else if let _ = x { + return true + } else if let _ = y { + return false + } + + return true + } + } + func subtracting(_ other: some Sequence, using keyPath: KeyPath) -> [Element] { filter { !other.contains($0[keyPath: keyPath]) } } diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index 09131724..2a47a5a0 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -81,7 +81,9 @@ extension String { .reduce("", +) } - static var emptyDash = "--" + static let emptyDash = "--" + + static let emptyTime = "--:--" var shortFileName: String { (split(separator: "/").last?.description ?? self) diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index f6ad2546..002c271b 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -315,4 +315,14 @@ extension View { ) ) } + + #if DEBUG + // Useful modifier during development + func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View { + background { + color + .opacity(opacity) + } + } + #endif } diff --git a/Shared/Objects/ChannelProgram.swift b/Shared/Objects/ChannelProgram.swift new file mode 100644 index 00000000..bd2704f4 --- /dev/null +++ b/Shared/Objects/ChannelProgram.swift @@ -0,0 +1,60 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +// Note: assumes programs are sorted by start date + +/// Structure that has a channel and associated programs. +struct ChannelProgram: Hashable, Identifiable { + + let channel: BaseItemDto + let programs: [BaseItemDto] + + var currentProgram: BaseItemDto? { + programs.first { program in + guard let start = program.startDate, + let end = program.endDate else { return false } + + return (start ... end).contains(Date.now) + } + } + + func programAfterCurrent(offset: Int) -> BaseItemDto? { + guard let currentStart = currentProgram?.startDate else { return nil } + + return programs.filter { program in + guard let start = program.startDate else { return false } + return start > currentStart + }[safe: offset] + } + + var id: String? { + channel.id + } +} + +extension ChannelProgram: Poster { + + var displayTitle: String { + channel.displayTitle + } + + var subtitle: String? { + nil + } + + var typeSystemImage: String? { + "tv" + } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { + channel.imageSource(.primary, maxWidth: maxWidth) + } +} diff --git a/Shared/Objects/LiveTVChannelProgram.swift b/Shared/Objects/LiveTVChannelProgram.swift deleted file mode 100644 index 6c0956c1..00000000 --- a/Shared/Objects/LiveTVChannelProgram.swift +++ /dev/null @@ -1,120 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation -import JellyfinAPI -import UIKit - -struct LiveTVChannelProgram: Hashable { - let id = UUID() - let channel: BaseItemDto - let currentProgram: BaseItemDto? - let programs: [BaseItemDto] -} - -extension LiveTVChannelProgram: Poster { - var displayTitle: String { - guard let currentProgram else { return "None" } - return currentProgram.displayTitle - } - - var title: String { - guard let currentProgram else { return "None" } - switch currentProgram.type { - case .episode: - return currentProgram.seriesName ?? currentProgram.displayTitle - default: - return currentProgram.displayTitle - } - } - - var subtitle: String? { - guard let currentProgram else { return "" } - switch currentProgram.type { - case .episode: - return currentProgram.seasonEpisodeLabel - case .video: - return currentProgram.extraType?.displayTitle - default: - return nil - } - } - - var showTitle: Bool { - guard let currentProgram else { return false } - switch currentProgram.type { - case .episode, .series, .movie, .boxSet, .collectionFolder: - return Defaults[.Customization.showPosterLabels] - default: - return true - } - } - - var typeSystemImage: String? { - guard let currentProgram else { return nil } - switch currentProgram.type { - case .episode, .movie, .series: - return "film" - case .folder: - return "folder.fill" - case .person: - return "person.fill" - case .boxSet: - return "film.stack" - default: return nil - } - } - - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - guard let currentProgram else { return ImageSource() } - switch currentProgram.type { - case .episode: - return currentProgram.seriesImageSource(.primary, maxWidth: maxWidth) - case .folder: - return ImageSource() - default: - return currentProgram.imageSource(.primary, maxWidth: maxWidth) - } - } - - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool = false) -> [ImageSource] { - guard let currentProgram else { return [] } - switch currentProgram.type { - case .episode: - if single || !Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { - return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] - } else { - return [ - currentProgram.seriesImageSource(.thumb, maxWidth: maxWidth), - currentProgram.seriesImageSource(.backdrop, maxWidth: maxWidth), - currentProgram.imageSource(.primary, maxWidth: maxWidth), - ] - } - case .folder: - return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] - case .video: - return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] - default: - return [ - currentProgram.imageSource(.thumb, maxWidth: maxWidth), - currentProgram.imageSource(.backdrop, maxWidth: maxWidth), - ] - } - } - - func cinematicPosterImageSources() -> [ImageSource] { - guard let currentProgram else { return [] } - switch currentProgram.type { - case .episode: - return [currentProgram.seriesImageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] - default: - return [currentProgram.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] - } - } -} diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift index 8edc1838..3696a90d 100644 --- a/Shared/Objects/Poster.swift +++ b/Shared/Objects/Poster.swift @@ -24,6 +24,18 @@ protocol Poster: Displayable, Hashable, Identifiable { extension Poster { + var showTitle: Bool { + true + } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { + .init() + } + + func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] { + [] + } + func cinematicPosterImageSources() -> [ImageSource] { [] } diff --git a/Shared/Objects/PosterType.swift b/Shared/Objects/PosterType.swift index 7a105b87..9670a564 100644 --- a/Shared/Objects/PosterType.swift +++ b/Shared/Objects/PosterType.swift @@ -9,9 +9,14 @@ import Defaults import SwiftUI -// TODO: Rename to `PosterDisplayType` or `PosterDisplay`? -// TODO: after no longer experimental, nest under `Poster` +// TODO: Refactor to `ItemDisplayType` +// - this is to move away from video specific to generalizing all media types. However, +// media is still able to use grammar for their own contexts. +// - move landscape/portrait to wide/narrow +// - add `square`/something similar +// TODO: after no longer experimental, nest under `Poster`? // tracker: https://github.com/apple/swift-evolution/blob/main/proposals/0404-nested-protocols.md + enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable { case landscape diff --git a/Shared/ViewModels/ChannelLibraryViewModel.swift b/Shared/ViewModels/ChannelLibraryViewModel.swift new file mode 100644 index 00000000..754d4e4b --- /dev/null +++ b/Shared/ViewModels/ChannelLibraryViewModel.swift @@ -0,0 +1,63 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Factory +import Foundation +import JellyfinAPI + +final class ChannelLibraryViewModel: PagingLibraryViewModel { + + override func get(page: Int) async throws -> [ChannelProgram] { + + var parameters = Paths.GetLiveTvChannelsParameters() + parameters.fields = .MinimumFields + parameters.userID = userSession.user.id + parameters.sortBy = [ItemSortBy.name.rawValue] + + parameters.limit = pageSize + parameters.startIndex = page * pageSize + + let request = Paths.getLiveTvChannels(parameters: parameters) + let response = try await userSession.client.send(request) + + let processedChannels = try await getPrograms(for: response.value.items ?? []) + + return processedChannels + } + + private func getPrograms(for channels: [BaseItemDto]) async throws -> [ChannelProgram] { + + guard let minEndDate = Calendar.current.date(byAdding: .hour, value: -1, to: .now), + let maxStartDate = Calendar.current.date(byAdding: .hour, value: 6, to: .now) else { return [] } + + var parameters = Paths.GetLiveTvProgramsParameters() + parameters.channelIDs = channels.compactMap(\.id) + parameters.userID = userSession.user.id + parameters.maxStartDate = maxStartDate + parameters.minEndDate = minEndDate + parameters.sortBy = ["StartDate"] + + let request = Paths.getLiveTvPrograms(parameters: parameters) + let response = try await userSession.client.send(request) + + let groupedPrograms = (response.value.items ?? []) + .grouped { program in + channels.first(where: { $0.id == program.channelID }) + } + + let channelPrograms: [ChannelProgram] = channels + .reduce(into: [:]) { partialResult, channel in + partialResult[channel] = (groupedPrograms[channel] ?? []) + .sorted(using: \.startDate) + } + .map(ChannelProgram.init) + .sorted(using: \.channel.name) + + return channelPrograms + } +} diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift deleted file mode 100644 index 9152ea2d..00000000 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ /dev/null @@ -1,204 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Factory -import Foundation -import Get -import JellyfinAPI - -extension Notification.Name { - static let livePlayerDismissed = Notification.Name("livePlayerDismissed") -} - -final class LiveTVChannelsViewModel: PagingLibraryViewModel { - - @Published - var channels: [BaseItemDto] = [] - @Published - var channelPrograms: [LiveTVChannelProgram] = [] - - private var programs = [BaseItemDto]() - private var channelProgramsList = [BaseItemDto: [BaseItemDto]]() - private var timer: Timer? - - var timeFormatter: DateFormatter { - let df = DateFormatter() - df.dateFormat = "h:mm" - return df - } - - init() { - super.init() - } - - override func get(page: Int) async throws -> [LiveTVChannelProgram] { - try await getChannelPrograms() - } - - deinit { - stopScheduleCheckTimer() - } - - private func getChannelPrograms() async throws -> [LiveTVChannelProgram] { - let _ = try await getGuideInfo() - let channelsResponse = try await getChannels() - guard let channels = channelsResponse.value.items, !channels.isEmpty else { - return [] - } - let programsResponse = try await getPrograms(channelIds: channels.compactMap(\.id)) - let fetchedPrograms = programsResponse.value.items ?? [] - await MainActor.run { - self.programs.append(contentsOf: fetchedPrograms) - } - var newChannelPrograms = [LiveTVChannelProgram]() - let now = Date() - for channel in channels { - let prgs = programs.filter { item in - item.channelID == channel.id - } - - var currentPrg: BaseItemDto? - for prg in prgs { - if let startDate = prg.startDate, - let endDate = prg.endDate, - now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && - now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate - { - currentPrg = prg - } - } - - newChannelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) - } - - return newChannelPrograms - } - - private func getGuideInfo() async throws -> Response { - let request = Paths.getGuideInfo - return try await userSession.client.send(request) - } - - func getChannels() async throws -> Response { - let parameters = Paths.GetLiveTvChannelsParameters( - userID: userSession.user.id, - startIndex: currentPage * pageSize, - limit: pageSize, - enableImageTypes: [.primary], - fields: ItemFields.MinimumFields, - enableUserData: false, - enableFavoriteSorting: true - ) - let request = Paths.getLiveTvChannels(parameters: parameters) - return try await userSession.client.send(request) - } - - private func getPrograms(channelIds: [String]) async throws -> Response { - let minEndDate = Date.now.addComponentsToDate(hours: -1) - let maxStartDate = minEndDate.addComponentsToDate(hours: 6) - let parameters = Paths.GetLiveTvProgramsParameters( - channelIDs: channelIds, - userID: userSession.user.id, - maxStartDate: maxStartDate, - minEndDate: minEndDate, - sortBy: ["StartDate"] - ) - let request = Paths.getLiveTvPrograms(parameters: parameters) - return try await userSession.client.send(request) - } - - func startScheduleCheckTimer() { - let date = Date() - let calendar = Calendar.current - var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date) - // Run every minute - guard let minute = components.minute else { return } - components.second = 0 - components.minute = minute + (1 - (minute % 1)) - guard let nextMinute = calendar.date(from: components) else { return } - if let existingTimer = timer { - existingTimer.invalidate() - } - timer = Timer(fire: nextMinute, interval: 60, repeats: true) { [weak self] _ in - guard let self = self else { return } - self.logger.debug("LiveTVChannels schedule check...") - - Task { - await MainActor.run { - let channelProgramsCopy = self.channelPrograms - var refreshedChannelPrograms: [LiveTVChannelProgram] = [] - for channelProgram in channelProgramsCopy { - var currentPrg: BaseItemDto? - let now = Date() - for prg in channelProgram.programs { - if let startDate = prg.startDate, - let endDate = prg.endDate, - now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && - now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate - { - currentPrg = prg - } - } - - refreshedChannelPrograms - .append(LiveTVChannelProgram( - channel: channelProgram.channel, - currentProgram: currentPrg, - programs: channelProgram.programs - )) - } - self.channelPrograms = refreshedChannelPrograms - } - } - } - if let timer = timer { - RunLoop.main.add(timer, forMode: .default) - } - } - - func stopScheduleCheckTimer() { - timer?.invalidate() - } -} - -extension Array { - func chunked(into size: Int) -> [[Element]] { - 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)! - } -} diff --git a/Shared/ViewModels/LiveTVProgramsViewModel.swift b/Shared/ViewModels/LiveTVProgramsViewModel.swift deleted file mode 100644 index 86fb3788..00000000 --- a/Shared/ViewModels/LiveTVProgramsViewModel.swift +++ /dev/null @@ -1,214 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI - -final class LiveTVProgramsViewModel: ViewModel { - - @Published - var recommendedItems = [BaseItemDto]() - @Published - var seriesItems = [BaseItemDto]() - @Published - var movieItems = [BaseItemDto]() - @Published - var sportsItems = [BaseItemDto]() - @Published - var kidsItems = [BaseItemDto]() - @Published - var newsItems = [BaseItemDto]() - - var channels = [String: BaseItemDto]() - - override init() { - super.init() - -// getChannels() - } - - func findChannel(id: String) -> BaseItemDto? { - channels[id] - } - - private func getChannels() { - Task { - let parameters = Paths.GetLiveTvChannelsParameters( - userID: userSession.user.id, - startIndex: 0, - limit: 1000, - enableImageTypes: [.primary], - enableUserData: false, - enableFavoriteSorting: true - ) - let request = Paths.getLiveTvChannels(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let channels = response.value.items else { return } - - for channel in channels { - guard let channelID = channel.id else { continue } - self.channels[channelID] = channel - } - - getRecommendedPrograms() - getSeries() - getMovies() - getSports() - getKids() - getNews() - } - } - - private func getRecommendedPrograms() { - Task { - let parameters = Paths.GetRecommendedProgramsParameters( - userID: userSession.user.id, - limit: 9, - isAiring: true, - imageTypeLimit: 1, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio], - enableTotalRecordCount: false - ) - let request = Paths.getRecommendedPrograms(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.recommendedItems = items - } - } - } - - private func getSeries() { - Task { - let request = Paths.getPrograms(.init( - enableImageTypes: [.primary, .thumb], - enableTotalRecordCount: false, - fields: [.channelInfo, .primaryImageAspectRatio], - hasAired: false, - isKids: false, - isMovie: false, - isNews: false, - isSeries: true, - isSports: false, - limit: 9, - userID: userSession.user.id - )) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.seriesItems = items - } - } - } - - private func getMovies() { - Task { - let request = Paths.getPrograms(.init( - enableImageTypes: [.primary, .thumb], - enableTotalRecordCount: false, - fields: [.channelInfo, .primaryImageAspectRatio], - hasAired: false, - isKids: false, - isMovie: true, - isNews: false, - isSeries: false, - isSports: false, - limit: 9, - userID: userSession.user.id - )) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.movieItems = items - } - } - } - - private func getSports() { - Task { - let request = Paths.getPrograms(.init( - enableImageTypes: [.primary, .thumb], - enableTotalRecordCount: false, - fields: [.channelInfo, .primaryImageAspectRatio], - hasAired: false, - isKids: false, - isMovie: false, - isNews: false, - isSeries: false, - isSports: true, - limit: 9, - userID: userSession.user.id - )) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.sportsItems = items - } - } - } - - private func getKids() { - Task { - let request = Paths.getPrograms(.init( - enableImageTypes: [.primary, .thumb], - enableTotalRecordCount: false, - fields: [.channelInfo, .primaryImageAspectRatio], - hasAired: false, - isKids: true, - isMovie: false, - isNews: false, - isSeries: false, - isSports: false, - limit: 9, - userID: userSession.user.id - )) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.kidsItems = items - } - } - } - - private func getNews() { - Task { - let request = Paths.getPrograms(.init( - enableImageTypes: [.primary, .thumb], - enableTotalRecordCount: false, - fields: [.channelInfo, .primaryImageAspectRatio], - hasAired: false, - isKids: false, - isMovie: false, - isNews: true, - isSeries: false, - isSports: false, - limit: 9, - userID: userSession.user.id - )) - let response = try await userSession.client.send(request) - - guard let items = response.value.items else { return } - - await MainActor.run { - self.seriesItems = items - } - } - } -} diff --git a/Shared/ViewModels/LiveVideoPlayerManager.swift b/Shared/ViewModels/LiveVideoPlayerManager.swift index 990893fb..e2014e68 100644 --- a/Shared/ViewModels/LiveVideoPlayerManager.swift +++ b/Shared/ViewModels/LiveVideoPlayerManager.swift @@ -12,11 +12,9 @@ import JellyfinAPI class LiveVideoPlayerManager: VideoPlayerManager { @Published - var program: LiveTVChannelProgram? - @Published - var dateFormatter = DateFormatter() + var program: ChannelProgram? - init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: LiveTVChannelProgram? = nil) { + init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: ChannelProgram? = nil) { self.program = program super.init() diff --git a/Shared/ViewModels/ProgramsViewModel.swift b/Shared/ViewModels/ProgramsViewModel.swift new file mode 100644 index 00000000..40a7571d --- /dev/null +++ b/Shared/ViewModels/ProgramsViewModel.swift @@ -0,0 +1,206 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI + +// TODO: is current program-channel requesting best way to do it? + +// Note: section item limit is low so that total channel amount is not too much + +final class ProgramsViewModel: ViewModel, Stateful { + + enum ProgramSection: CaseIterable { + case kids + case movies + case news + case recommended + case series + case sports + } + + // MARK: Action + + enum Action: Equatable { + case error(JellyfinAPIError) + case refresh + } + + // MARK: State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } + + @Published + private(set) var kids: [ChannelProgram] = [] + @Published + private(set) var movies: [ChannelProgram] = [] + @Published + private(set) var news: [ChannelProgram] = [] + @Published + private(set) var recommended: [ChannelProgram] = [] + @Published + private(set) var series: [ChannelProgram] = [] + @Published + private(set) var sports: [ChannelProgram] = [] + + @Published + final var lastAction: Action? = nil + @Published + final var state: State = .initial + + private var programChannels: [BaseItemDto] = [] + + private var currentRefreshTask: AnyCancellable? + + var hasNoResults: Bool { + kids.isEmpty && + movies.isEmpty && + news.isEmpty && + recommended.isEmpty && + series.isEmpty && + sports.isEmpty + } + + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + case .refresh: + currentRefreshTask?.cancel() + + currentRefreshTask = Task { [weak self] in + guard let self else { return } + + do { + let sections = try await getItemSections() + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.kids = sections[.kids] ?? [] + self.movies = sections[.movies] ?? [] + self.news = sections[.news] ?? [] + self.recommended = sections[.recommended] ?? [] + self.series = sections[.series] ?? [] + self.sports = sections[.sports] ?? [] + + self.state = .content + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self.send(.error(.init(error.localizedDescription))) + } + } + } + .asAnyCancellable() + + return .refreshing + } + } + + private func getItemSections() async throws -> [ProgramSection: [ChannelProgram]] { + try await withThrowingTaskGroup( + of: (ProgramSection, [BaseItemDto]).self, + returning: [ProgramSection: [ChannelProgram]].self + ) { group in + + // sections + for section in ProgramSection.allCases { + group.addTask { + let items = try await self.getPrograms(for: section) + return (section, items) + } + } + + // recommended + group.addTask { + let items = try await self.getRecommendedPrograms() + return (ProgramSection.recommended, items) + } + + var programs: [ProgramSection: [BaseItemDto]] = [:] + + while let items = try await group.next() { + programs[items.0] = items.1 + } + + // get channels for all programs at once to + // avoid going back and forth too much + let channels = try await Set(self.getChannels(for: programs.values.flatMap { $0 })) + + let result: [ProgramSection: [ChannelProgram]] = programs.mapValues { programs in + programs.compactMap { program in + guard let channel = channels.first(where: { channel in channel.id == program.channelID }) else { return nil } + return ChannelProgram(channel: channel, programs: [program]) + } + } + + return result + } + } + + private func getRecommendedPrograms() async throws -> [BaseItemDto] { + + var parameters = Paths.GetRecommendedProgramsParameters() + parameters.fields = .MinimumFields + .appending(.channelInfo) + parameters.isAiring = true + parameters.limit = 10 + parameters.userID = userSession.user.id + + let request = Paths.getRecommendedPrograms(parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func getPrograms(for section: ProgramSection) async throws -> [BaseItemDto] { + + var parameters = Paths.GetLiveTvProgramsParameters() + parameters.fields = .MinimumFields + .appending(.channelInfo) + .appending(.mediaSources) + parameters.hasAired = false + parameters.limit = 10 + parameters.userID = userSession.user.id + + parameters.isKids = section == .kids + parameters.isMovie = section == .movies + parameters.isNews = section == .news + parameters.isSeries = section == .series + parameters.isSports = section == .sports + + let request = Paths.getLiveTvPrograms(parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func getChannels(for programs: [BaseItemDto]) async throws -> [BaseItemDto] { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.ids = programs.compactMap(\.channelID) + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift index 00ad685d..ec337cdd 100644 --- a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift +++ b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift @@ -19,6 +19,7 @@ import VLCUI // TODO: should view models handle progress reports instead, with a protocol // for other types of media handling +// TODO: transition to `Stateful` class VideoPlayerManager: ViewModel { class CurrentProgressHandler: ObservableObject { diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 3f0d890f..1a56caa7 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -64,6 +64,7 @@ class VideoPlayerViewModel: ViewModel { return hlsStreamComponents.url! } + // TODO: should start time be from the media source instead? var vlcVideoPlayerConfiguration: VLCVideoPlayer.Configuration { let configuration = VLCVideoPlayer.Configuration(url: playbackURL) configuration.autoPlay = true diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift deleted file mode 100644 index 85e35ef6..00000000 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ /dev/null @@ -1,125 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct CutOffShadow: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let brs = CGPoint(x: rect.maxX, y: rect.maxY - 6) - let brc = CGPoint(x: rect.maxX - 6, y: rect.maxY - 6) - let bls = CGPoint(x: rect.minX + 6, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 6, y: rect.maxY - 6) - - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: brs) - path.addRelativeArc( - center: brc, - radius: 6, - startAngle: Angle.degrees(0), - delta: Angle.degrees(90) - ) - path.addLine(to: bls) - path.addRelativeArc( - center: blc, - radius: 6, - startAngle: Angle.degrees(90), - delta: Angle.degrees(90) - ) - - return path - } -} - -struct LandscapeItemElement: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var backgroundURL: URL? - - var item: BaseItemDto - var inSeasonView: Bool? - - var body: some View { - VStack { - ImageView(item.imageSource(.backdrop, maxWidth: 445)) - .frame(width: 445, height: 250) - .cornerRadius(10) - .ignoresSafeArea() - .overlay( - ZStack { - if item.userData?.isPlayed ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), - alignment: .topTrailing - ).opacity(1) - .overlay(ZStack(alignment: .leading) { - if focused && item.userData?.playedPercentage != nil { - Rectangle() - .fill(LinearGradient( - gradient: Gradient(colors: [.black, .clear]), - startPoint: .bottom, - endPoint: .top - )) - .frame(width: 445, height: 90) - .mask(CutOffShadow()) - VStack(alignment: .leading) { - Text("CONTINUE • \(item.progressLabel ?? "")") - .font(.caption) - .fontWeight(.medium) - .offset(y: 5) - 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(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) - } - }.padding(12) - } else { - EmptyView() - } - }, alignment: .bottomLeading) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - if inSeasonView ?? false { - Text("\(item.episodeLocator ?? "") • \(item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } else { - Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.episodeLocator ?? "")" : item.name ?? "") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } -} diff --git a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift index cde36cb5..be5e9e0c 100644 --- a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift +++ b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift @@ -10,8 +10,13 @@ import SwiftUI struct LandscapePosterProgressBar: View { - let title: String - let progress: CGFloat + private let title: String? + private let progress: Double + + init(title: String? = nil, progress: Double) { + self.title = title + self.progress = progress + } var body: some View { ZStack(alignment: .bottom) { @@ -26,15 +31,16 @@ struct LandscapePosterProgressBar: View { VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.subheadline) - .foregroundColor(.white) + if let title { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + } ProgressBar(progress: progress) .frame(height: 5) } - .padding(.horizontal, 5) - .padding(.bottom, 7) + .padding(10) } } } diff --git a/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift deleted file mode 100644 index a4ea9f05..00000000 --- a/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift +++ /dev/null @@ -1,178 +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 (c) 2024 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 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 { - VStack { - HStack { - Text(channel.number ?? "") - .font(.footnote) - .frame(alignment: .leading) - .padding() - Spacer() - }.frame(alignment: .top) - Spacer() - } - - GeometryReader { gp in - VStack { - ImageView(channel.imageSource(.primary, 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)) - - programLabel( - timeText: currentProgramText.timeDisplay, - titleText: currentProgramText.title, - color: Color.primary, - font: Font.system(size: 20, weight: .bold, design: .default) - ) - if nextProgramsText.isNotEmpty { - 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) - } - - 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)) - } - } - } - .frame(minWidth: 400, minHeight: 400) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4) - ) - .cornerRadius(20) - .scaleEffect(isFocused ? 1.1 : 1) - .focusable(true) - .focused($focused) - .onChange(of: focused) { foc in - withAnimation(.linear(duration: 0.15)) { - self.isFocused = foc - } - } - .onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) { - onSelect { loadingState in - loading = loadingState - } - } - } - - @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) - } - } -} diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift new file mode 100644 index 00000000..d54213b3 --- /dev/null +++ b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -0,0 +1,69 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Foundation +import JellyfinAPI +import SwiftUI + +struct ChannelLibraryView: View { + + @EnvironmentObject + private var router: VideoPlayerWrapperCoordinator.Router + + @StateObject + private var viewModel = ChannelLibraryViewModel() + + private var contentView: some View { + CollectionVGrid( + $viewModel.elements, + layout: .columns(3, insets: .init(0), itemSpacing: 25, lineSpacing: 25) + ) { channel in + WideChannelGridItem(channel: channel) + .onSelect { + guard let mediaSource = channel.channel.mediaSources?.first else { return } + router.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) + ) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + } + + var body: some View { + WrappedView { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } + } + .ignoresSafeArea() + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .afterLastDisappear { interval in + // refresh after 3 hours + if interval >= 10800 { + viewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift new file mode 100644 index 00000000..6f22dfae --- /dev/null +++ b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift @@ -0,0 +1,154 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension ChannelLibraryView { + + struct WideChannelGridItem: View { + + @Default(.accentColor) + private var accentColor + + @State + private var now: Date = .now + + let channel: ChannelProgram + + private var onSelect: () -> Void + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + private var channelLogo: some View { + VStack { + ZStack { + Color.clear + + ImageView(channel.portraitPosterImageSource(maxWidth: 110)) + .image { + $0.aspectRatio(contentMode: .fit) + } + .failure { + SystemImageContentView(systemName: channel.typeSystemImage) + .background(color: .clear) + .imageFrameRatio(width: 1.5, height: 1.5) + } + .placeholder { + EmptyView() + } + } + .aspectRatio(1.0, contentMode: .fit) + + Text(channel.channel.number ?? "") + .font(.body) + .lineLimit(1) + .foregroundStyle(.primary) + } + } + + @ViewBuilder + private func programLabel(for program: BaseItemDto) -> some View { + HStack(alignment: .top, spacing: EdgeInsets.defaultEdgePadding / 2) { + AlternateLayoutView(alignment: .leading) { + Text("00:00 AM") + .monospacedDigit() + } content: { + if let startDate = program.startDate { + Text(startDate, style: .time) + .monospacedDigit() + } else { + Text(String.emptyTime) + } + } + + Text(program.displayTitle) + } + .lineLimit(1) + } + + @ViewBuilder + private var programListView: some View { + VStack(alignment: .leading, spacing: 0) { + if let currentProgram = channel.currentProgram { + ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) + .frame(height: 8) + .padding(.bottom, 8) + .foregroundStyle(accentColor) + + programLabel(for: currentProgram) + .font(.caption.weight(.bold)) + } + + if let nextProgram = channel.programAfterCurrent(offset: 0) { + programLabel(for: nextProgram) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let futureProgram = channel.programAfterCurrent(offset: 1) { + programLabel(for: futureProgram) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .id(channel.currentProgram) + } + + var body: some View { + Button { + onSelect() + } label: { + HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding / 2) { + + channelLogo + .frame(width: 110) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(channel.displayTitle) + .font(.body) + .fontWeight(.bold) + .lineLimit(1) + .foregroundStyle(.primary) + + if channel.programs.isNotEmpty { + programListView + } + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, EdgeInsets.defaultEdgePadding / 2) + } + .buttonStyle(.card) + .frame(height: 200) + .onReceive(timer) { newValue in + now = newValue + } + .animation(.linear(duration: 0.2), value: channel.currentProgram) + } + } +} + +extension ChannelLibraryView.WideChannelGridItem { + + init(channel: ChannelProgram) { + self.init( + channel: channel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift deleted file mode 100644 index 1c1765ae..00000000 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ /dev/null @@ -1,161 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Foundation -import JellyfinAPI -import SwiftUI - -typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) - -struct LiveTVChannelsView: View { - - @EnvironmentObject - private var router: LiveTVChannelsCoordinator.Router - - @StateObject - var viewModel = LiveTVChannelsViewModel() - - @ViewBuilder - private var loadingView: some View { - ProgressView() - } - - // TODO: add retry - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - @ViewBuilder - private var channelsView: some View { - Group { - if viewModel.isLoading { - ProgressView() - } else if viewModel.elements.isNotEmpty { - CollectionVGrid( - $viewModel.elements, - layout: .minWidth(400, itemSpacing: 16, lineSpacing: 4) - ) { program in - channelCell(for: program) - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text(L10n.noResults) - Button { - viewModel.send(.refresh) - } label: { - Text(L10n.reload) - } - } - } - } - .ignoresSafeArea() - } - - @ViewBuilder - private func channelCell(for 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 - } - - LiveTVChannelItemElement( - channel: channel, - currentProgram: channelProgram.currentProgram, - currentProgramText: currentProgramDisplayText, - nextProgramsText: nextProgramsDisplayText( - nextItems: nextItems, - timeFormatter: viewModel.timeFormatter - ), - onSelect: { _ in - guard let mediaSource = channel.mediaSources?.first else { - return - } - viewModel.stopScheduleCheckTimer() - router.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channel, mediaSource: mediaSource, program: channelProgram) - ) - } - ) - } - - var body: some View { - Group { - if viewModel.isLoading && viewModel.elements.isEmpty { - loadingView - } else if viewModel.elements.isEmpty { - noResultsView - } else { - channelsView - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - } - - private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { - var programsDisplayText: [LiveTVChannelViewProgram] = [] - for item in nextItems { - programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) - } - return programsDisplayText - } -} - -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) - } -} diff --git a/Swiftfin tvOS/Views/LiveTVHomeView.swift b/Swiftfin tvOS/Views/LiveTVHomeView.swift deleted file mode 100644 index dcf943e2..00000000 --- a/Swiftfin tvOS/Views/LiveTVHomeView.swift +++ /dev/null @@ -1,22 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct LiveTVHomeView: View { - - @EnvironmentObject - var mainCoordinator: MainCoordinator.Router - - var body: some View { - Button("Return Home") - .onAppear { - mainCoordinator.root(\.mainTab) - } - } -} diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift deleted file mode 100644 index b11b7fe9..00000000 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ /dev/null @@ -1,179 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Foundation -import SwiftUI - -struct LiveTVProgramsView: View { - - @EnvironmentObject - private var router: LiveTVProgramsCoordinator.Router - - @StateObject - var viewModel: LiveTVProgramsViewModel - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading) { - if viewModel.recommendedItems.isNotEmpty { - let items = viewModel.recommendedItems - Text("On Now") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if viewModel.seriesItems.isNotEmpty { - let items = viewModel.seriesItems - Text("Shows") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if viewModel.movieItems.isNotEmpty { - let items = viewModel.movieItems - Text("Movies") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if viewModel.sportsItems.isNotEmpty { - let items = viewModel.sportsItems - Text("Sports") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if viewModel.kidsItems.isNotEmpty { - let items = viewModel.kidsItems - Text("Kids") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if viewModel.newsItems.isNotEmpty { - let items = viewModel.newsItems - Text("News") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - guard let channelID = item.channelID, - let channel = viewModel.findChannel(id: channelID), - let mediaSource = channel.mediaSources?.first else { return } - - router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - } - } - .edgesIgnoringSafeArea(.bottom) - .edgesIgnoringSafeArea(.horizontal) - } -} diff --git a/Swiftfin tvOS/Views/MediaView/MediaItem.swift b/Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift similarity index 100% rename from Swiftfin tvOS/Views/MediaView/MediaItem.swift rename to Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift diff --git a/Swiftfin tvOS/Views/MediaView/MediaView.swift b/Swiftfin tvOS/Views/MediaView/MediaView.swift index a179388e..6778606e 100644 --- a/Swiftfin tvOS/Views/MediaView/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView/MediaView.swift @@ -14,8 +14,6 @@ import SwiftUI struct MediaView: View { - @EnvironmentObject - private var mainRouter: MainCoordinator.Router @EnvironmentObject private var router: MediaCoordinator.Router @@ -36,7 +34,8 @@ struct MediaView: View { filters: .default ) router.route(to: \.library, viewModel) - case .downloads: () + case .downloads: + assertionFailure("Downloads unavailable on tvOS") case .favorites: let viewModel = ItemLibraryViewModel( title: L10n.favorites, @@ -44,7 +43,7 @@ struct MediaView: View { ) router.route(to: \.library, viewModel) case .liveTV: - mainRouter.root(\.liveTV) + router.route(to: \.liveTV) } } } diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift new file mode 100644 index 00000000..f0d9bf96 --- /dev/null +++ b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift @@ -0,0 +1,53 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ProgramsView { + + struct ProgramButtonContent: View { + + let program: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + + Text(program.channelName ?? .emptyDash) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + + Text(program.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + + HStack(spacing: 2) { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + Text("-") + + if let endDate = program.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift new file mode 100644 index 00000000..793b809a --- /dev/null +++ b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -0,0 +1,37 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ProgramsView { + + struct ProgramProgressOverlay: View { + + @State + private var programProgress: Double = 0.0 + + let program: BaseItemDto + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + WrappedView { + if let startDate = program.startDate, startDate < Date.now { + LandscapePosterProgressBar( + progress: program.programProgress ?? 0 + ) + } + } + .onReceive(timer) { newValue in + if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { + programProgress = newValue.timeIntervalSince(startDate) / duration + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift new file mode 100644 index 00000000..300a1525 --- /dev/null +++ b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift @@ -0,0 +1,101 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +// TODO: background refresh for programs with timer? + +// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program + +struct ProgramsView: View { + + @EnvironmentObject + private var router: VideoPlayerWrapperCoordinator.Router + + @StateObject + private var programsViewModel = ProgramsViewModel() + + private var contentView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if programsViewModel.recommended.isNotEmpty { + programsSection(title: L10n.onNow, keyPath: \.recommended) + } + + if programsViewModel.series.isNotEmpty { + programsSection(title: L10n.series, keyPath: \.series) + } + + if programsViewModel.movies.isNotEmpty { + programsSection(title: L10n.movies, keyPath: \.movies) + } + + if programsViewModel.kids.isNotEmpty { + programsSection(title: L10n.kids, keyPath: \.kids) + } + + if programsViewModel.sports.isNotEmpty { + programsSection(title: L10n.sports, keyPath: \.sports) + } + + if programsViewModel.news.isNotEmpty { + programsSection(title: L10n.news, keyPath: \.news) + } + } + } + } + + @ViewBuilder + private func programsSection( + title: String, + keyPath: KeyPath + ) -> some View { + PosterHStack( + title: title, + type: .landscape, + items: programsViewModel[keyPath: keyPath] + ) + .content { + ProgramButtonContent(program: $0.programs[0]) + } + .imageOverlay { + ProgramProgressOverlay(program: $0.programs[0]) + } + .onSelect { channelProgram in + guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } + router.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) + ) + } + } + + var body: some View { + WrappedView { + switch programsViewModel.state { + case .content: + if programsViewModel.hasNoResults { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } + } + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + if programsViewModel.state == .initial { + programsViewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift index 4359774b..1f56fa19 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -44,9 +44,6 @@ struct LiveNativeVideoPlayer: View { } .navigationBarHidden(true) .ignoresSafeArea() - .onDisappear { - NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) - } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift index 285439e0..990a45ff 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift @@ -53,14 +53,14 @@ extension LiveVideoPlayer.Overlay { var body: some View { VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { - if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) { - Text(subtitle.title) - .font(.subheadline) - .foregroundColor(.white) - .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in - dimensions[.leading] - } - } +// if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) { +// Text(subtitle.title) +// .font(.subheadline) +// .foregroundColor(.white) +// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in +// dimensions[.leading] +// } +// } HStack { diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift index 2ef283be..b777954e 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift @@ -80,9 +80,6 @@ struct LiveVideoPlayer: View { .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) } } - .onDisappear { - NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) - } } @ViewBuilder diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index ab1e30d3..5265c3b8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -149,15 +149,9 @@ BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; BD0BA22E2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; }; BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; }; - 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 */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; }; C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; }; - C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; - C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; - C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; }; C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; }; C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */; }; @@ -174,27 +168,33 @@ C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */; }; C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; }; C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */; }; - C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; }; - C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; }; - C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; - C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; - C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; - C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; - C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */; }; - C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */; }; - C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */; }; - C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */; }; - C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; }; - C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; }; - 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 */; }; C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* MediaView.swift */; }; - C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; }; E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; + E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */; }; + E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */; }; + E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */; }; + E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */; }; + E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231332BCF8A3C009D71FC /* ProgramsView.swift */; }; + E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */; }; + E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */; }; + E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; }; + E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; }; + E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */; }; + E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */; }; + E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */; }; + E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */; }; + E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */; }; + E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */; }; + E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */; }; + E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */; }; + E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */; }; + E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */; }; + E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231562BCF8AF8009D71FC /* ProgramsView.swift */; }; + E102315F2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */; }; + E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */; }; E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */; }; E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; }; E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; @@ -563,7 +563,6 @@ E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; - E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; }; E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; }; E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.swift */; }; @@ -641,7 +640,6 @@ E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; }; E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */; }; E1B90C8A2BC475E7007027C8 /* ScalingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */; }; - E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */; }; E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */; }; @@ -834,7 +832,6 @@ 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; @@ -953,12 +950,9 @@ AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = ""; }; BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; 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 = ""; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = ""; }; C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = ""; }; C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = ""; }; - C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerManager.swift; sourceTree = ""; }; C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoadingOverlay.swift; sourceTree = ""; }; C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerCoordinator.swift; sourceTree = ""; }; @@ -973,21 +967,25 @@ C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = ""; }; C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = ""; }; C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = ""; }; - C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelProgram.swift; sourceTree = ""; }; - C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; - C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; - C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = ""; }; - 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 = ""; }; - 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 /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.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 = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; + E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSLiveTVCoordinator.swift; sourceTree = ""; }; + E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVCoordinator.swift; sourceTree = ""; }; + E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = ""; }; + E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = ""; }; + E10231332BCF8A3C009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = ""; }; + E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = ""; }; + E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = ""; }; + E10231432BCF8A51009D71FC /* ChannelProgram.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelProgram.swift; sourceTree = ""; }; + E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryViewModel.swift; sourceTree = ""; }; + E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsViewModel.swift; sourceTree = ""; }; + E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlternateLayoutView.swift; sourceTree = ""; }; + E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = ""; }; + E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = ""; }; + E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = ""; }; + E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = ""; }; + E10231562BCF8AF8009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = ""; }; + E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerWrapperCoordinator.swift; sourceTree = ""; }; E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; E103DF942BCF31CD000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = ""; }; @@ -1246,7 +1244,6 @@ E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = ""; }; - E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = ""; }; E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AspectFillActionButton.swift; sourceTree = ""; }; @@ -1507,15 +1504,16 @@ 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( + E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, E107BB9127880A4000354E07 /* ItemViewModel */, E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, - C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, - C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, + C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, E1CAF65C2BA345830087D991 /* MediaViewModel */, + E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, @@ -1524,7 +1522,6 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */, - C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); @@ -1613,6 +1610,7 @@ children = ( E1D4BF802719D22800A11E64 /* AppAppearance.swift */, E129429728F4785200796AC6 /* CaseIterablePicker.swift */, + E10231432BCF8A51009D71FC /* ChannelProgram.swift */, E17FB55128C119D400311DFE /* Displayable.swift */, E1579EA62B97DC1500A31CA1 /* Eventful.swift */, E1092F4B29106F9F00163F57 /* GestureAction.swift */, @@ -1642,7 +1640,6 @@ E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, E15756352936856700976E1F /* VideoPlayerType.swift */, - C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */, ); path = Objects; sourceTree = ""; @@ -1656,9 +1653,7 @@ E1C92618288756BD002A7A66 /* DotHStack.swift */, E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */, E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */, - E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */, E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */, - C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */, E10E842B29A589860064EA49 /* NonePosterButton.swift */, E111D8F928D0400900400001 /* PagingLibraryView.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */, @@ -1969,10 +1964,7 @@ 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, - C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */, - C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */, - C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */, - C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, + E102312B2BCF8A08009D71FC /* LiveTVCoordinator */, C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, E193D5412719404B00900D82 /* MainCoordinator */, 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */, @@ -1986,6 +1978,7 @@ E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, + E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */, ); path = Coordinators; sourceTree = ""; @@ -2056,6 +2049,93 @@ path = Components; sourceTree = ""; }; + E102312B2BCF8A08009D71FC /* LiveTVCoordinator */ = { + isa = PBXGroup; + children = ( + E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */, + E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */, + ); + path = LiveTVCoordinator; + sourceTree = ""; + }; + E10231322BCF8A3C009D71FC /* Components */ = { + isa = PBXGroup; + children = ( + E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */, + E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */, + ); + path = Components; + sourceTree = ""; + }; + E10231342BCF8A3C009D71FC /* ProgramsView */ = { + isa = PBXGroup; + children = ( + E10231322BCF8A3C009D71FC /* Components */, + E10231332BCF8A3C009D71FC /* ProgramsView.swift */, + ); + path = ProgramsView; + sourceTree = ""; + }; + E10231362BCF8A3C009D71FC /* Component */ = { + isa = PBXGroup; + children = ( + E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */, + ); + path = Component; + sourceTree = ""; + }; + E10231382BCF8A3C009D71FC /* ChannelLibraryView */ = { + isa = PBXGroup; + children = ( + E10231362BCF8A3C009D71FC /* Component */, + E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */, + ); + path = ChannelLibraryView; + sourceTree = ""; + }; + E10231502BCF8AF8009D71FC /* Components */ = { + isa = PBXGroup; + children = ( + E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */, + ); + path = Components; + sourceTree = ""; + }; + E10231522BCF8AF8009D71FC /* ChannelLibraryView */ = { + isa = PBXGroup; + children = ( + E10231502BCF8AF8009D71FC /* Components */, + E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */, + ); + path = ChannelLibraryView; + sourceTree = ""; + }; + E10231552BCF8AF8009D71FC /* Components */ = { + isa = PBXGroup; + children = ( + E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */, + E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */, + ); + path = Components; + sourceTree = ""; + }; + E10231572BCF8AF8009D71FC /* ProgramsView */ = { + isa = PBXGroup; + children = ( + E10231552BCF8AF8009D71FC /* Components */, + E10231562BCF8AF8009D71FC /* ProgramsView.swift */, + ); + path = ProgramsView; + sourceTree = ""; + }; + E102315D2BCF8B36009D71FC /* Components */ = { + isa = PBXGroup; + children = ( + E103DF942BCF31CD000229B2 /* MediaItem.swift */, + ); + path = Components; + sourceTree = ""; + }; E103DF912BCF2F1F000229B2 /* Components */ = { isa = PBXGroup; children = ( @@ -2076,8 +2156,8 @@ E103DF932BCF31C5000229B2 /* MediaView */ = { isa = PBXGroup; children = ( + E102315D2BCF8B36009D71FC /* Components */, C4E508172703E8190045C9AB /* MediaView.swift */, - E103DF942BCF31CD000229B2 /* MediaItem.swift */, ); path = MediaView; sourceTree = ""; @@ -2228,16 +2308,15 @@ isa = PBXGroup; children = ( E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, + E10231522BCF8AF8009D71FC /* ChannelLibraryView */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, E154967B296CBB1A00C4EF88 /* FontPickerView.swift */, E1A42E4D28CBD3B200A14DCB /* HomeView */, E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */, E193D54E271942C000900D82 /* ItemView */, - C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */, - C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, - C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */, E103DF932BCF31C5000229B2 /* MediaView */, + E10231572BCF8AF8009D71FC /* ProgramsView */, E1E1643928BAC2EF00323B0A /* SearchView.swift */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, @@ -2294,6 +2373,7 @@ E18E01F3288747580022598C /* AboutAppView.swift */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, + E10231382BCF8A3C009D71FC /* ChannelLibraryView */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, E13332922953BA9400EE76AB /* DownloadTaskView */, @@ -2302,15 +2382,11 @@ E168BD07289A4162001A6922 /* HomeView */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E14F7D0A26DB3714007C3AE6 /* ItemView */, - C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, - C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, - C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, - C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, - C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, E103DF922BCF2F23000229B2 /* MediaView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, + E10231342BCF8A3C009D71FC /* ProgramsView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, @@ -2759,12 +2835,12 @@ E1AD105326D96F5A003E4A08 /* Components */ = { isa = PBXGroup; children = ( + E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, E18E0203288749200022598C /* BlurView.swift */, E1153DCB2BBB633B00424D36 /* FastSVGView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, - 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, E1DC983F296DEBA500982F06 /* PosterIndicators */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */, E187A60129AB28F0008387E6 /* RotateContentView.swift */, @@ -3402,7 +3478,6 @@ E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, - C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, @@ -3431,6 +3506,7 @@ E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, @@ -3439,13 +3515,12 @@ E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */, + E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, - C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */, - C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, @@ -3454,13 +3529,14 @@ E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */, - E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */, E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */, E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, + E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, + E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */, C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, @@ -3489,7 +3565,6 @@ E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */, E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */, - C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, @@ -3505,7 +3580,6 @@ E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */, - E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */, E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */, 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, @@ -3516,7 +3590,6 @@ E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, - C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, @@ -3537,13 +3610,11 @@ E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, - C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, - C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */, E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */, E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, @@ -3569,7 +3640,6 @@ E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, - C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, @@ -3580,7 +3650,9 @@ E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */, + E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */, E1E6C45129B104850064123F /* Button.swift in Sources */, + E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */, E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */, E11042762B8013DF00821020 /* Stateful.swift in Sources */, @@ -3638,10 +3710,11 @@ E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, - C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */, E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, + E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */, E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, + E102315F2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */, E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */, E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */, @@ -3665,6 +3738,7 @@ E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */, + E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */, E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, @@ -3676,19 +3750,20 @@ E193D547271941C500900D82 /* UserListView.swift in Sources */, E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, + E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */, E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, + E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */, E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */, E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, - C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */, E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, @@ -3740,7 +3815,7 @@ E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, - C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, + E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, @@ -3778,7 +3853,6 @@ E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, - C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, @@ -3795,16 +3869,15 @@ E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, - C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, + E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E17FB55228C119D400311DFE /* Displayable.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, - C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */, E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, @@ -3818,7 +3891,6 @@ E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, - C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, @@ -3839,12 +3911,9 @@ E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, - C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, - C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, - C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -3853,6 +3922,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, + E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, @@ -3880,6 +3950,7 @@ C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, + E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */, E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, @@ -3894,6 +3965,7 @@ C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, + E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, @@ -3906,6 +3978,7 @@ E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, + E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, @@ -3916,8 +3989,10 @@ E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, + E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, + E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */, E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */, E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */, @@ -3941,6 +4016,7 @@ E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */, E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, @@ -3948,7 +4024,6 @@ 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, - C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */, E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, @@ -3958,7 +4033,6 @@ E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, - C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E129429328F2845000796AC6 /* SliderType.swift in Sources */, E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */, @@ -3982,7 +4056,6 @@ E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */, E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, E18ACA952A15A3E100BB4F35 /* (null) in Sources */, - C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, @@ -3999,10 +4072,10 @@ 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, + E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */, E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, - C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, @@ -4058,6 +4131,7 @@ E18E01EA288747230022598C /* MovieItemView.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, E148128528C15472003B8787 /* SortOrder.swift in Sources */, + E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, diff --git a/Swiftfin/Components/LandscapePosterProgressBar.swift b/Swiftfin/Components/LandscapePosterProgressBar.swift index 2fb6c5c2..01f4ec91 100644 --- a/Swiftfin/Components/LandscapePosterProgressBar.swift +++ b/Swiftfin/Components/LandscapePosterProgressBar.swift @@ -9,22 +9,28 @@ import Defaults import SwiftUI -struct LandscapePosterProgressBar: View { +// TODO: fix relative padding, or remove? +// TODO: gradient should grow/shrink with content, not relative to container + +struct LandscapePosterProgressBar: View { @Default(.accentColor) private var accentColor - let title: String - let progress: CGFloat - // Scale padding depending on view width @State private var paddingScale: CGFloat = 1.0 @State private var width: CGFloat = 0 + private let content: () -> Content + private let progress: Double + var body: some View { ZStack(alignment: .bottom) { + + Color.clear + LinearGradient( stops: [ .init(color: .clear, location: 0), @@ -37,11 +43,7 @@ struct LandscapePosterProgressBar: View { VStack(alignment: .leading, spacing: 3 * paddingScale) { - Spacer() - - Text(title) - .font(.subheadline) - .foregroundColor(.white) + content() ProgressBar(progress: progress) .foregroundColor(accentColor) @@ -55,3 +57,43 @@ struct LandscapePosterProgressBar: View { } } } + +extension LandscapePosterProgressBar where Content == Text { + + init( + title: String, + progress: Double + ) { + self.init( + content: { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + }, + progress: progress + ) + } +} + +extension LandscapePosterProgressBar where Content == EmptyView { + + init(progress: Double) { + self.init( + content: { EmptyView() }, + progress: progress + ) + } +} + +extension LandscapePosterProgressBar { + + init( + progress: Double, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + content: content, + progress: progress + ) + } +} diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 8cfb9bc3..543f73fb 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -11,6 +11,7 @@ import JellyfinAPI import SwiftUI // TODO: expose `ImageView.image` modifier for image aspect fill/fit +// TODO: allow `content` to trigger `onSelect`? struct PosterButton: View { diff --git a/Swiftfin/Resources/Assets.xcassets/ShadowColor.colorset/Contents.json b/Swiftfin/Resources/Assets.xcassets/ShadowColor.colorset/Contents.json deleted file mode 100644 index 1264d8ba..00000000 --- a/Swiftfin/Resources/Assets.xcassets/ShadowColor.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "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 - } -} diff --git a/Swiftfin/Resources/Assets.xcassets/TextHighlightColor.colorset/Contents.json b/Swiftfin/Resources/Assets.xcassets/TextHighlightColor.colorset/Contents.json deleted file mode 100644 index 76b961cc..00000000 --- a/Swiftfin/Resources/Assets.xcassets/TextHighlightColor.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "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 - } -} diff --git a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift new file mode 100644 index 00000000..158d6080 --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -0,0 +1,100 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import Foundation +import JellyfinAPI +import SwiftUI + +// TODO: wide + narrow view toggling +// - after `PosterType` has been refactored and with customizable toggle button +// TODO: sorting by number/filtering +// - should be able to use normal filter view model, but how to add custom filters for data context? + +struct ChannelLibraryView: View { + + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + + @State + private var layout: CollectionVGridLayout + + @StateObject + private var viewModel = ChannelLibraryViewModel() + + init() { + if UIDevice.isPhone { + layout = .columns(1) + } else { + layout = .minWidth(250) + } + } + + private var contentView: some View { + CollectionVGrid( + $viewModel.elements, + layout: layout + ) { channel in + WideChannelGridItem(channel: channel) + .onSelect { + guard let mediaSource = channel.channel.mediaSources?.first else { return } + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) + ) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + WrappedView { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .navigationTitle(L10n.channels) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .afterLastDisappear { interval in + // refresh after 3 hours + if interval >= 10800 { + viewModel.send(.refresh) + } + } + .topBarTrailing { + + if viewModel.backgroundStates.contains(.gettingNextPage) { + ProgressView() + } + } + } +} diff --git a/Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift b/Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift new file mode 100644 index 00000000..ee8a940f --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift @@ -0,0 +1,169 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: can look busy with 3 programs, probably just do 2? + +extension ChannelLibraryView { + + struct WideChannelGridItem: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.colorScheme) + private var colorScheme + + @State + private var contentSize: CGSize = .zero + @State + private var now: Date = .now + + let channel: ChannelProgram + + private var onSelect: () -> Void + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + private var channelLogo: some View { + VStack { + ZStack { + Color.secondarySystemFill + .opacity(colorScheme == .dark ? 0.5 : 1) + .posterShadow() + + ImageView(channel.portraitPosterImageSource(maxWidth: 80)) + .image { + $0.aspectRatio(contentMode: .fit) + } + .failure { + SystemImageContentView(systemName: channel.typeSystemImage) + .background(color: .clear) + .imageFrameRatio(width: 2, height: 2) + } + .placeholder { + EmptyView() + } + .padding(2) + } + .aspectRatio(1.0, contentMode: .fill) + .cornerRadius(ratio: 0.0375, of: \.width) + + Text(channel.channel.number ?? "") + .font(.body) + .lineLimit(1) + .foregroundColor(Color.jellyfinPurple) + } + } + + @ViewBuilder + private func programLabel(for program: BaseItemDto) -> some View { + HStack(alignment: .top) { + AlternateLayoutView(alignment: .leading) { + Text(Date(timeIntervalSince1970: 0), style: .time) + .monospacedDigit() + } content: { + if let startDate = program.startDate { + Text(startDate, style: .time) + .monospacedDigit() + } else { + Text(String.emptyTime) + } + } + + Text(program.displayTitle) + } + .lineLimit(1) + } + + @ViewBuilder + private var programListView: some View { + VStack(alignment: .leading, spacing: 0) { + if let currentProgram = channel.currentProgram { + ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) + .frame(height: 5) + .padding(.bottom, 5) + .foregroundStyle(accentColor) + + programLabel(for: currentProgram) + .font(.footnote.weight(.bold)) + } + + if let nextProgram = channel.programAfterCurrent(offset: 0) { + programLabel(for: nextProgram) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let futureProgram = channel.programAfterCurrent(offset: 1) { + programLabel(for: futureProgram) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .id(channel.currentProgram) + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Button { + onSelect() + } label: { + HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding) { + + channelLogo + .frame(width: 80) + .padding(.vertical, 8) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(channel.displayTitle) + .font(.body) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundStyle(.primary) + + if channel.programs.isNotEmpty { + programListView + } + } + + Spacer() + } + .frame(maxWidth: .infinity) + .size($contentSize) + } + } + .buttonStyle(.plain) + + Color.secondarySystemFill + .frame(width: contentSize.width, height: 1) + } + .onReceive(timer) { newValue in + now = newValue + } + .animation(.linear(duration: 0.2), value: channel.currentProgram) + } + } +} + +extension ChannelLibraryView.WideChannelGridItem { + + init(channel: ChannelProgram) { + self.init( + channel: channel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift deleted file mode 100644 index b2681765..00000000 --- a/Swiftfin/Views/LiveTVChannelItemElement.swift +++ /dev/null @@ -1,124 +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 (c) 2024 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.imageURL(.primary, 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 deleted file mode 100644 index b71359a1..00000000 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ /dev/null @@ -1,176 +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 (c) 2024 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(spacing: 0) { - VStack(spacing: 0) { - ZStack(alignment: .center) { - ImageView(channel.imageURL(.primary, maxWidth: 56)) - .aspectRatio(contentMode: .fit) - .frame(width: 56, height: 56) - - if loading { - - ProgressView() - } - } - .padding(.top, 4) - .padding(.leading, 4) - - VStack(alignment: .leading) { - Text(channel.number != nil ? "\(channel.number ?? "") " : "") - .font(.body) - .lineLimit(1) - .foregroundColor(Color.jellyfinPurple) - .frame(alignment: .leading) - .padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0)) - } - .padding(.top, 4) - } - .frame(alignment: .leading) - .opacity(loading ? 0.5 : 1.0) - - VStack(alignment: .leading, spacing: 0) { - Text("\(channel.name ?? "")") - .font(.body) - .bold() - .lineLimit(1) - .foregroundColor(Color.jellyfinPurple) - .frame(alignment: .leading) - - progressBar() - .padding(.top, 4) - - HStack { - Text(currentProgramText.timeDisplay) - .font(.footnote) - .bold() - .lineLimit(1) - .foregroundColor(Color("TextHighlightColor")) - .frame(width: 38, alignment: .leading) - - Text(currentProgramText.title) - .font(.footnote) - .bold() - .lineLimit(1) - .foregroundColor(Color("TextHighlightColor")) - } - .padding(.top, 4) - - if !nextProgramsText.isEmpty { - let nextItem = nextProgramsText[0] - programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray) - } - if nextProgramsText.count > 1 { - let nextItem2 = nextProgramsText[1] - programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray) - } - - Spacer() - } - .padding(8) - - Spacer() - } - } - .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.secondarySystemFill)) - .onTapGesture { - onSelect { loadingState in - loading = loadingState - } - } - } - - @ViewBuilder - func progressBar() -> some View { - VStack(alignment: .center) { - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) - } - } - .frame(height: 6, alignment: .center) - } - } - - @ViewBuilder - func programLabel(timeText: String, titleText: String, color: Color) -> some View { - HStack(alignment: .top) { - Text(timeText) - .font(.footnote) - .lineLimit(1) - .foregroundColor(color) - .frame(width: 38, alignment: .leading) - Text(titleText) - .font(.footnote) - .lineLimit(1) - .foregroundColor(color) - } - } -} diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift deleted file mode 100644 index 4500c49a..00000000 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ /dev/null @@ -1,136 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Foundation -import JellyfinAPI -import SwiftUI - -typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) - -struct LiveTVChannelsView: View { - - @EnvironmentObject - private var liveTVRouter: LiveTVCoordinator.Router - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - - @StateObject - var viewModel = LiveTVChannelsViewModel() - - @ViewBuilder - private func channelCell(for 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: { _ in - guard let mediaSource = channel.mediaSources?.first else { - return - } - viewModel.stopScheduleCheckTimer() - mainRouter.route(to: \.liveVideoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) - } - ) - } - - var body: some View { - Group { - if viewModel.isLoading { - ProgressView() - } else if viewModel.elements.isNotEmpty { - CollectionVGrid( - $viewModel.elements, - layout: .minWidth(250, itemSpacing: 16, lineSpacing: 4) - ) { program in - channelCell(for: program) - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text(L10n.noResults) - Button { - viewModel.send(.refresh) - } label: { - Text(L10n.reload) - } - } - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .navigationBarTitleDisplayMode(.inline) - } - - 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) - } -} diff --git a/Swiftfin/Views/LiveTVHomeView.swift b/Swiftfin/Views/LiveTVHomeView.swift deleted file mode 100644 index 06d218a1..00000000 --- a/Swiftfin/Views/LiveTVHomeView.swift +++ /dev/null @@ -1,16 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Stinsen -import SwiftUI - -struct LiveTVHomeView: View { - var body: some View { - Text(L10n.comingSoon) - } -} diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift deleted file mode 100644 index 066e7245..00000000 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ /dev/null @@ -1,103 +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 (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Stinsen -import SwiftUI - -struct LiveTVProgramsView: View { - - @EnvironmentObject - private var programsRouter: LiveTVProgramsCoordinator.Router - @StateObject - var viewModel = LiveTVProgramsViewModel() - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading) { - if viewModel.recommendedItems.isNotEmpty { - let items = viewModel.recommendedItems -// PosterHStack(title: L10n.onNow, type: .portrait, items: items) -// .onSelect { 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.isNotEmpty { - let items = viewModel.seriesItems -// PosterHStack(title: L10n.tvShows, type: .portrait, items: items) -// .onSelect { 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.isNotEmpty { - let items = viewModel.movieItems -// PosterHStack(title: L10n.movies, type: .portrait, items: items) -// .onSelect { 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.isNotEmpty { - let items = viewModel.sportsItems -// PosterHStack(title: L10n.sports, type: .portrait, items: items) -// .onSelect { 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.isNotEmpty { - let items = viewModel.kidsItems -// PosterHStack(title: L10n.kids, type: .portrait, items: items) -// .onSelect { 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.isNotEmpty { - let items = viewModel.newsItems -// PosterHStack(title: L10n.news, type: .portrait, items: items) -// .onSelect { 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) - //// } -// } -// } - } - } - } - } -} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift index 5a5ba11b..89612350 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -93,7 +93,7 @@ extension PagingLibraryView { VStack(alignment: .leading, spacing: 5) { Text(item.displayTitle) .font(posterType == .landscape ? .subheadline : .callout) - .fontWeight(.regular) + .fontWeight(.semibold) .foregroundColor(.primary) .lineLimit(2) .multilineTextAlignment(.leading) diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift new file mode 100644 index 00000000..f0d9bf96 --- /dev/null +++ b/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift @@ -0,0 +1,53 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ProgramsView { + + struct ProgramButtonContent: View { + + let program: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + + Text(program.channelName ?? .emptyDash) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + + Text(program.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + + HStack(spacing: 2) { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + Text("-") + + if let endDate = program.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift new file mode 100644 index 00000000..793b809a --- /dev/null +++ b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -0,0 +1,37 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ProgramsView { + + struct ProgramProgressOverlay: View { + + @State + private var programProgress: Double = 0.0 + + let program: BaseItemDto + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + WrappedView { + if let startDate = program.startDate, startDate < Date.now { + LandscapePosterProgressBar( + progress: program.programProgress ?? 0 + ) + } + } + .onReceive(timer) { newValue in + if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { + programProgress = newValue.timeIntervalSince(startDate) / duration + } + } + } + } +} diff --git a/Swiftfin/Views/ProgramsView/ProgramsView.swift b/Swiftfin/Views/ProgramsView/ProgramsView.swift new file mode 100644 index 00000000..d669ffbf --- /dev/null +++ b/Swiftfin/Views/ProgramsView/ProgramsView.swift @@ -0,0 +1,147 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +// TODO: background refresh for programs with timer? +// TODO: find other another way to handle channels/other views? + +// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program + +struct ProgramsView: View { + + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + @EnvironmentObject + private var router: LiveTVCoordinator.Router + + @StateObject + private var programsViewModel = ProgramsViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + programsViewModel.send(.refresh) + } + } + + private var liveTVSectionScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + liveTVSectionPill( + title: L10n.channels, + systemImage: "play.square.stack" + ) { + router.route(to: \.channels) + } + } + .edgePadding(.horizontal) + } + } + + // TODO: probably make own pill view + // - see if could merge with item view pills + private func liveTVSectionPill(title: String, systemImage: String, onSelect: @escaping () -> Void) -> some View { + Button { + onSelect() + } label: { + Label(title, systemImage: systemImage) + .font(.callout.weight(.semibold)) + .foregroundColor(.primary) + .padding(8) + .background { + Color.systemFill + .cornerRadius(10) + } + } + } + + private var contentView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + + liveTVSectionScrollView + + if programsViewModel.hasNoResults { + // TODO: probably change to "No Programs" + L10n.noResults.text + } + + if programsViewModel.recommended.isNotEmpty { + programsSection(title: L10n.onNow, keyPath: \.recommended) + } + + if programsViewModel.series.isNotEmpty { + programsSection(title: L10n.series, keyPath: \.series) + } + + if programsViewModel.movies.isNotEmpty { + programsSection(title: L10n.movies, keyPath: \.movies) + } + + if programsViewModel.kids.isNotEmpty { + programsSection(title: L10n.kids, keyPath: \.kids) + } + + if programsViewModel.sports.isNotEmpty { + programsSection(title: L10n.sports, keyPath: \.sports) + } + + if programsViewModel.news.isNotEmpty { + programsSection(title: L10n.news, keyPath: \.news) + } + } + } + } + + @ViewBuilder + private func programsSection( + title: String, + keyPath: KeyPath + ) -> some View { + PosterHStack( + title: title, + type: .landscape, + items: programsViewModel[keyPath: keyPath] + ) + .content { + ProgramButtonContent(program: $0.programs[0]) + } + .imageOverlay { + ProgramProgressOverlay(program: $0.programs[0]) + } + .onSelect { channelProgram in + guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) + ) + } + } + + var body: some View { + WrappedView { + switch programsViewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .navigationTitle(L10n.liveTV) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + if programsViewModel.state == .initial { + programsViewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift index d5b1691d..2ccef396 100644 --- a/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -40,9 +40,6 @@ struct LiveNativeVideoPlayer: View { .navigationBarHidden() .statusBarHidden() .ignoresSafeArea() - .onDisappear { - NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) - } } } diff --git a/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift index 3dd77a66..1f505d74 100644 --- a/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift @@ -170,9 +170,6 @@ struct LiveVideoPlayer: View { gestureStateHandler: gestureStateHandler, updateViewProxy: updateViewProxy ) - .onDisappear { - NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) - } } var body: some View { @@ -221,9 +218,6 @@ struct LiveVideoPlayer: View { audioOffset = 0 subtitleOffset = 0 } - .onDisappear { - NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) - } } }