Live TV Updates (#1022)

This commit is contained in:
Ethan Pippin 2024-04-16 23:10:44 -06:00 committed by GitHub
parent 913dda5fea
commit ec9bfaa2fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1720 additions and 2204 deletions

View File

@ -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<Content: View, Layout: View>: 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()
}
}
}

View File

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

View File

@ -8,8 +8,7 @@
import SwiftUI import SwiftUI
// TODO: Replace scaling with size so that the Capsule corner radius // TODO: see if animation is correct here or should be in caller views
// is not affected
struct ProgressBar: View { struct ProgressBar: View {
@ -18,12 +17,15 @@ struct ProgressBar: View {
var body: some View { var body: some View {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Capsule() Capsule()
.foregroundColor(.white) .foregroundColor(.secondary)
.opacity(0.2) .opacity(0.2)
Capsule() 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)
} }
} }

View File

@ -8,20 +8,28 @@
import SwiftUI import SwiftUI
// TODO: is the background color setting really the best way?
struct SystemImageContentView: View { struct SystemImageContentView: View {
@State @State
private var contentSize: CGSize = .zero private var contentSize: CGSize = .zero
private var backgroundColor: Color
private var heightRatio: CGFloat
private let systemName: String private let systemName: String
private var widthRatio: CGFloat
init(systemName: String?) { init(systemName: String?) {
self.backgroundColor = Color.secondarySystemFill
self.heightRatio = 3
self.systemName = systemName ?? "circle" self.systemName = systemName ?? "circle"
self.widthRatio = 3.5
} }
var body: some View { var body: some View {
ZStack { ZStack {
Color.secondarySystemFill backgroundColor
.opacity(0.5) .opacity(0.5)
Image(systemName: systemName) Image(systemName: systemName)
@ -29,8 +37,20 @@ struct SystemImageContentView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.accessibilityHidden(true) .accessibilityHidden(true)
.frame(width: contentSize.width / 3.5, height: contentSize.height / 3) .frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
} }
.size($contentSize) .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)
}
}

View File

@ -18,8 +18,15 @@ final class LiveTVCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
@Route(.push)
var channels = makeChannels
func makeChannels() -> ChannelLibraryView {
ChannelLibraryView()
}
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LiveTVChannelsView() ProgramsView()
} }
} }

View File

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

View File

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

View File

@ -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<LiveTVChannelsCoordinator> {
NavigationViewCoordinator(LiveTVChannelsCoordinator())
}
@ViewBuilder
func makeChannelsTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "square.grid.3x3")
L10n.channels.text
}
}
func makePrograms() -> NavigationViewCoordinator<LiveTVProgramsCoordinator> {
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
}
}
}

View File

@ -38,18 +38,20 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
LiveNativeVideoPlayer(manager: self.videoPlayerManager) LiveNativeVideoPlayer(manager: self.videoPlayerManager)
} }
} }
.preferredColorScheme(.dark)
.supportedOrientations(UIDevice.isPhone ? .landscape : .allButUpsideDown)
} }
.ignoresSafeArea() .ignoresSafeArea()
.backport
.persistentSystemOverlays(.hidden)
#else #else
PreferencesView { PreferencesView {
Group { if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { LiveVideoPlayer(manager: self.videoPlayerManager)
LiveVideoPlayer(manager: self.videoPlayerManager) } else {
} else { LiveNativeVideoPlayer(manager: self.videoPlayerManager)
LiveNativeVideoPlayer(manager: self.videoPlayerManager)
}
} }
} }
.ignoresSafeArea() .ignoresSafeArea()

View File

@ -23,10 +23,6 @@ final class MainCoordinator: NavigationCoordinatable {
var mainTab = makeMainTab var mainTab = makeMainTab
@Root @Root
var serverList = makeServerList var serverList = makeServerList
@Root
var liveTV = makeLiveTV
// @Route(.fullScreen)
// var videoPlayer = makeVideoPlayer
init() { init() {
@ -65,12 +61,4 @@ final class MainCoordinator: NavigationCoordinatable {
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> { func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator()) NavigationViewCoordinator(ServerListCoordinator())
} }
func makeLiveTV() -> LiveTVTabCoordinator {
LiveTVTabCoordinator()
}
// func makeVideoPlayer(parameters: VideoPlayerCoordinator.Parameters) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
// NavigationViewCoordinator(VideoPlayerCoordinator(parameters: parameters))
// }
} }

View File

@ -12,6 +12,7 @@ import Stinsen
import SwiftUI import SwiftUI
final class MainTabCoordinator: TabCoordinatable { final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [ var child = TabChild(startingItems: [
\MainTabCoordinator.home, \MainTabCoordinator.home,
\MainTabCoordinator.tvShows, \MainTabCoordinator.tvShows,

View File

@ -20,6 +20,8 @@ final class MediaCoordinator: NavigationCoordinatable {
#if os(tvOS) #if os(tvOS)
@Route(.modal) @Route(.modal)
var library = makeLibrary var library = makeLibrary
@Route(.modal)
var liveTV = makeLiveTV
#else #else
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
@ -33,21 +35,20 @@ final class MediaCoordinator: NavigationCoordinatable {
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> { func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
} }
#else #else
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> { func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel) LibraryCoordinator(viewModel: viewModel)
} }
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
func makeDownloads() -> DownloadListCoordinator { func makeDownloads() -> DownloadListCoordinator {
DownloadListCoordinator() DownloadListCoordinator()
} }
#endif #endif
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
MediaView() MediaView()

View File

@ -49,8 +49,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable {
.ignoresSafeArea() .ignoresSafeArea()
.backport .backport
.persistentSystemOverlays(.hidden) .persistentSystemOverlays(.hidden)
.backport
.defersSystemGestures(on: .all)
#else #else
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {

View File

@ -6,32 +6,36 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Defaults
import Foundation
import JellyfinAPI
import Stinsen import Stinsen
import SwiftUI 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 @Root
var start = makeStart var start = makeStart
#if os(tvOS)
@Route(.fullScreen) @Route(.fullScreen)
var liveVideoPlayer = makeLiveVideoPlayer 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<LiveVideoPlayerCoordinator> { func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator<LiveVideoPlayerCoordinator> {
NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager)) NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager))
} }
#endif
@ViewBuilder @ViewBuilder
func makeStart() -> some View { private func makeStart() -> some View {
LiveTVChannelsView() content()
.eraseToAnyView()
} }
} }

View File

@ -46,14 +46,16 @@ extension BaseItemDto: Poster {
var typeSystemImage: String? { var typeSystemImage: String? {
switch type { switch type {
case .boxSet:
"film.stack"
case .episode, .movie, .series: case .episode, .movie, .series:
"film" "film"
case .folder: case .folder:
"folder.fill" "folder.fill"
case .person: case .person:
"person.fill" "person.fill"
case .boxSet: case .program:
"film.stack" "tv"
default: nil default: nil
} }
} }
@ -83,6 +85,8 @@ extension BaseItemDto: Poster {
} }
case .folder: case .folder:
return [imageSource(.primary, maxWidth: maxWidth)] return [imageSource(.primary, maxWidth: maxWidth)]
case .program:
return [imageSource(.primary, maxWidth: maxWidth)]
case .video: case .video:
return [imageSource(.primary, maxWidth: maxWidth)] return [imageSource(.primary, maxWidth: maxWidth)]
default: default:

View File

@ -12,6 +12,8 @@ import Foundation
import JellyfinAPI import JellyfinAPI
import UIKit import UIKit
// TODO: clean up
extension BaseItemDto: Displayable { extension BaseItemDto: Displayable {
var displayTitle: String { var displayTitle: String {
@ -97,18 +99,27 @@ extension BaseItemDto {
return " " return " "
} }
func getLiveProgressPercentage() -> Double { var programDuration: TimeInterval? {
if let startDate, guard let startDate, let endDate else { return nil }
let endDate return endDate.timeIntervalSince(startDate)
{ }
let start = startDate.timeIntervalSinceReferenceDate
let end = endDate.timeIntervalSinceReferenceDate var programProgress: Double? {
let now = Date().timeIntervalSinceReferenceDate guard let startDate, let endDate else { return nil }
let length = end - start
let progress = now - start let length = endDate.timeIntervalSince(startDate)
return progress / length let progress = Date.now.timeIntervalSince(startDate)
}
return 0 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] { var subtitleStreams: [MediaStream] {

View File

@ -24,6 +24,27 @@ extension Sequence {
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) 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<Key: Comparable>(using keyPath: KeyPath<Element, Key?>) -> [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<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] { func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
filter { !other.contains($0[keyPath: keyPath]) } filter { !other.contains($0[keyPath: keyPath]) }
} }

View File

@ -81,7 +81,9 @@ extension String {
.reduce("", +) .reduce("", +)
} }
static var emptyDash = "--" static let emptyDash = "--"
static let emptyTime = "--:--"
var shortFileName: String { var shortFileName: String {
(split(separator: "/").last?.description ?? self) (split(separator: "/").last?.description ?? self)

View File

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

View File

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

View File

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

View File

@ -24,6 +24,18 @@ protocol Poster: Displayable, Hashable, Identifiable {
extension Poster { extension Poster {
var showTitle: Bool {
true
}
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
.init()
}
func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] {
[]
}
func cinematicPosterImageSources() -> [ImageSource] { func cinematicPosterImageSources() -> [ImageSource] {
[] []
} }

View File

@ -9,9 +9,14 @@
import Defaults import Defaults
import SwiftUI import SwiftUI
// TODO: Rename to `PosterDisplayType` or `PosterDisplay`? // TODO: Refactor to `ItemDisplayType`
// TODO: after no longer experimental, nest under `Poster` // - 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 // tracker: https://github.com/apple/swift-evolution/blob/main/proposals/0404-nested-protocols.md
enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable { enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable {
case landscape case landscape

View File

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

View File

@ -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<LiveTVChannelProgram> {
@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<GuideInfo> {
let request = Paths.getGuideInfo
return try await userSession.client.send(request)
}
func getChannels() async throws -> Response<BaseItemDtoQueryResult> {
let parameters = Paths.GetLiveTvChannelsParameters(
userID: userSession.user.id,
startIndex: currentPage * pageSize,
limit: pageSize,
enableImageTypes: [.primary],
fields: ItemFields.MinimumFields,
enableUserData: false,
enableFavoriteSorting: true
)
let request = Paths.getLiveTvChannels(parameters: parameters)
return try await userSession.client.send(request)
}
private func getPrograms(channelIds: [String]) async throws -> Response<BaseItemDtoQueryResult> {
let minEndDate = Date.now.addComponentsToDate(hours: -1)
let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
let parameters = Paths.GetLiveTvProgramsParameters(
channelIDs: channelIds,
userID: userSession.user.id,
maxStartDate: maxStartDate,
minEndDate: minEndDate,
sortBy: ["StartDate"]
)
let request = Paths.getLiveTvPrograms(parameters: parameters)
return try await userSession.client.send(request)
}
func startScheduleCheckTimer() {
let date = Date()
let calendar = Calendar.current
var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date)
// Run 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)!
}
}

View File

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

View File

@ -12,11 +12,9 @@ import JellyfinAPI
class LiveVideoPlayerManager: VideoPlayerManager { class LiveVideoPlayerManager: VideoPlayerManager {
@Published @Published
var program: LiveTVChannelProgram? var program: ChannelProgram?
@Published
var dateFormatter = DateFormatter()
init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: LiveTVChannelProgram? = nil) { init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: ChannelProgram? = nil) {
self.program = program self.program = program
super.init() super.init()

View File

@ -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 ?? []
}
}

View File

@ -19,6 +19,7 @@ import VLCUI
// TODO: should view models handle progress reports instead, with a protocol // TODO: should view models handle progress reports instead, with a protocol
// for other types of media handling // for other types of media handling
// TODO: transition to `Stateful`
class VideoPlayerManager: ViewModel { class VideoPlayerManager: ViewModel {
class CurrentProgressHandler: ObservableObject { class CurrentProgressHandler: ObservableObject {

View File

@ -64,6 +64,7 @@ class VideoPlayerViewModel: ViewModel {
return hlsStreamComponents.url! return hlsStreamComponents.url!
} }
// TODO: should start time be from the media source instead?
var vlcVideoPlayerConfiguration: VLCVideoPlayer.Configuration { var vlcVideoPlayerConfiguration: VLCVideoPlayer.Configuration {
let configuration = VLCVideoPlayer.Configuration(url: playbackURL) let configuration = VLCVideoPlayer.Configuration(url: playbackURL)
configuration.autoPlay = true configuration.autoPlay = true

View File

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

View File

@ -10,8 +10,13 @@ import SwiftUI
struct LandscapePosterProgressBar: View { struct LandscapePosterProgressBar: View {
let title: String private let title: String?
let progress: CGFloat private let progress: Double
init(title: String? = nil, progress: Double) {
self.title = title
self.progress = progress
}
var body: some View { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
@ -26,15 +31,16 @@ struct LandscapePosterProgressBar: View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(title) if let title {
.font(.subheadline) Text(title)
.foregroundColor(.white) .font(.subheadline)
.foregroundColor(.white)
}
ProgressBar(progress: progress) ProgressBar(progress: progress)
.frame(height: 5) .frame(height: 5)
} }
.padding(.horizontal, 5) .padding(10)
.padding(.bottom, 7)
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,8 +14,6 @@ import SwiftUI
struct MediaView: View { struct MediaView: View {
@EnvironmentObject
private var mainRouter: MainCoordinator.Router
@EnvironmentObject @EnvironmentObject
private var router: MediaCoordinator.Router private var router: MediaCoordinator.Router
@ -36,7 +34,8 @@ struct MediaView: View {
filters: .default filters: .default
) )
router.route(to: \.library, viewModel) router.route(to: \.library, viewModel)
case .downloads: () case .downloads:
assertionFailure("Downloads unavailable on tvOS")
case .favorites: case .favorites:
let viewModel = ItemLibraryViewModel( let viewModel = ItemLibraryViewModel(
title: L10n.favorites, title: L10n.favorites,
@ -44,7 +43,7 @@ struct MediaView: View {
) )
router.route(to: \.library, viewModel) router.route(to: \.library, viewModel)
case .liveTV: case .liveTV:
mainRouter.root(\.liveTV) router.route(to: \.liveTV)
} }
} }
} }

View File

@ -0,0 +1,53 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import 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)
}
}
}
}

View File

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

View File

@ -0,0 +1,101 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import 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<ProgramsViewModel, [ChannelProgram]>
) -> 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)
}
}
}
}

View File

@ -44,9 +44,6 @@ struct LiveNativeVideoPlayer: View {
} }
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
.onDisappear {
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
}
} }
} }

View File

@ -53,14 +53,14 @@ extension LiveVideoPlayer.Overlay {
var body: some View { var body: some View {
VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) {
if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) { // if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) {
Text(subtitle.title) // Text(subtitle.title)
.font(.subheadline) // .font(.subheadline)
.foregroundColor(.white) // .foregroundColor(.white)
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in // .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
dimensions[.leading] // dimensions[.leading]
} // }
} // }
HStack { HStack {

View File

@ -80,9 +80,6 @@ struct LiveVideoPlayer: View {
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
} }
} }
.onDisappear {
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -149,15 +149,9 @@
BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; };
BD0BA22E2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; }; BD0BA22E2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; };
BD0BA22F2AD6508C00306A8D /* 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 */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; };
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; }; C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; };
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.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 */; }; C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; };
C45C36552A8B1F2C003DAE46 /* 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 */; }; 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 */; }; C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */; };
C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; }; C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; };
C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.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 */; }; 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 */; }; E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; };
E1002B652793CEE800E47059 /* 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 */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; };
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* 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 */; }; E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */; };
E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; }; E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; };
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.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 */; }; E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; };
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.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 */; }; E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; };
E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; }; E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; };
E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.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 */; }; E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; };
E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */; }; E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */; };
E1B90C8A2BC475E7007027C8 /* ScalingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.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 */; }; E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; };
E1BDF2E62951475300CC0294 /* 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 */; }; 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 = "<group>"; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = "<group>"; };
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
@ -953,12 +950,9 @@
AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = "<group>"; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = "<group>"; };
BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = "<group>"; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = "<group>"; };
BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; sourceTree = "<group>"; }; BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; sourceTree = "<group>"; };
C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = "<group>"; };
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = "<group>"; };
C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = "<group>"; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = "<group>"; };
C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = "<group>"; }; C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = "<group>"; };
C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = "<group>"; }; C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = "<group>"; };
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = "<group>"; };
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerManager.swift; sourceTree = "<group>"; }; C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerManager.swift; sourceTree = "<group>"; };
C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoadingOverlay.swift; sourceTree = "<group>"; }; C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoadingOverlay.swift; sourceTree = "<group>"; };
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerCoordinator.swift; sourceTree = "<group>"; }; C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
@ -973,21 +967,25 @@
C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = "<group>"; }; C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = "<group>"; };
C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = "<group>"; }; C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = "<group>"; };
C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = "<group>"; }; C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = "<group>"; };
C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelProgram.swift; sourceTree = "<group>"; };
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = "<group>"; };
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = "<group>"; };
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = "<group>"; };
C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = "<group>"; };
C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsViewModel.swift; sourceTree = "<group>"; };
C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVTabCoordinator.swift; sourceTree = "<group>"; };
C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsViewModel.swift; sourceTree = "<group>"; };
C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsCoordinator.swift; sourceTree = "<group>"; };
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = "<group>"; };
C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = "<group>"; };
C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; }; C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; };
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = "<group>"; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = "<group>"; };
E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = "<group>"; };
E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = "<group>"; };
E10231332BCF8A3C009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = "<group>"; };
E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = "<group>"; };
E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = "<group>"; };
E10231432BCF8A51009D71FC /* ChannelProgram.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelProgram.swift; sourceTree = "<group>"; };
E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryViewModel.swift; sourceTree = "<group>"; };
E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsViewModel.swift; sourceTree = "<group>"; };
E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlternateLayoutView.swift; sourceTree = "<group>"; };
E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = "<group>"; };
E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = "<group>"; };
E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = "<group>"; };
E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = "<group>"; };
E10231562BCF8AF8009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = "<group>"; };
E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerWrapperCoordinator.swift; sourceTree = "<group>"; };
E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = "<group>"; }; E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = "<group>"; };
E103DF942BCF31CD000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = "<group>"; }; E103DF942BCF31CD000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = "<group>"; };
E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = "<group>"; }; E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = "<group>"; };
@ -1246,7 +1244,6 @@
E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = "<group>"; }; E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = "<group>"; };
E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = "<group>"; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = "<group>"; };
E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = "<group>"; }; E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = "<group>"; };
E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = "<group>"; };
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = "<group>"; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = "<group>"; };
E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = "<group>"; }; E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = "<group>"; };
E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AspectFillActionButton.swift; sourceTree = "<group>"; }; E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AspectFillActionButton.swift; sourceTree = "<group>"; };
@ -1507,15 +1504,16 @@
532175392671BCED005491E6 /* ViewModels */ = { 532175392671BCED005491E6 /* ViewModels */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */,
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
E113133928BEB71D00930F75 /* FilterViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */,
625CB5722678C32A00530A6E /* HomeViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */,
E107BB9127880A4000354E07 /* ItemViewModel */, E107BB9127880A4000354E07 /* ItemViewModel */,
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */,
E1CAF65C2BA345830087D991 /* MediaViewModel */, E1CAF65C2BA345830087D991 /* MediaViewModel */,
E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */,
6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */,
62E632DB267D2E130063E547 /* SearchViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */,
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
@ -1524,7 +1522,6 @@
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3F82717E961009D4DAF /* UserListViewModel.swift */,
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
BD0BA2292AD6501300306A8D /* VideoPlayerManager */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
625CB57B2678CE1000530A6E /* ViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */,
); );
@ -1613,6 +1610,7 @@
children = ( children = (
E1D4BF802719D22800A11E64 /* AppAppearance.swift */, E1D4BF802719D22800A11E64 /* AppAppearance.swift */,
E129429728F4785200796AC6 /* CaseIterablePicker.swift */, E129429728F4785200796AC6 /* CaseIterablePicker.swift */,
E10231432BCF8A51009D71FC /* ChannelProgram.swift */,
E17FB55128C119D400311DFE /* Displayable.swift */, E17FB55128C119D400311DFE /* Displayable.swift */,
E1579EA62B97DC1500A31CA1 /* Eventful.swift */, E1579EA62B97DC1500A31CA1 /* Eventful.swift */,
E1092F4B29106F9F00163F57 /* GestureAction.swift */, E1092F4B29106F9F00163F57 /* GestureAction.swift */,
@ -1642,7 +1640,6 @@
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */,
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
E15756352936856700976E1F /* VideoPlayerType.swift */, E15756352936856700976E1F /* VideoPlayerType.swift */,
C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */,
); );
path = Objects; path = Objects;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1656,9 +1653,7 @@
E1C92618288756BD002A7A66 /* DotHStack.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */,
E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */, E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */,
E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */, E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */,
E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */,
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */, E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */,
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */,
E10E842B29A589860064EA49 /* NonePosterButton.swift */, E10E842B29A589860064EA49 /* NonePosterButton.swift */,
E111D8F928D0400900400001 /* PagingLibraryView.swift */, E111D8F928D0400900400001 /* PagingLibraryView.swift */,
E1C92617288756BD002A7A66 /* PosterButton.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */,
@ -1969,10 +1964,7 @@
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */,
C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */, E102312B2BCF8A08009D71FC /* LiveTVCoordinator */,
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */,
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */,
C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */,
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
E193D5412719404B00900D82 /* MainCoordinator */, E193D5412719404B00900D82 /* MainCoordinator */,
62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */, 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */,
@ -1986,6 +1978,7 @@
E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */,
E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */,
E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */,
E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */,
); );
path = Coordinators; path = Coordinators;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2056,6 +2049,93 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E102312B2BCF8A08009D71FC /* LiveTVCoordinator */ = {
isa = PBXGroup;
children = (
E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */,
E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */,
);
path = LiveTVCoordinator;
sourceTree = "<group>";
};
E10231322BCF8A3C009D71FC /* Components */ = {
isa = PBXGroup;
children = (
E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */,
E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */,
);
path = Components;
sourceTree = "<group>";
};
E10231342BCF8A3C009D71FC /* ProgramsView */ = {
isa = PBXGroup;
children = (
E10231322BCF8A3C009D71FC /* Components */,
E10231332BCF8A3C009D71FC /* ProgramsView.swift */,
);
path = ProgramsView;
sourceTree = "<group>";
};
E10231362BCF8A3C009D71FC /* Component */ = {
isa = PBXGroup;
children = (
E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */,
);
path = Component;
sourceTree = "<group>";
};
E10231382BCF8A3C009D71FC /* ChannelLibraryView */ = {
isa = PBXGroup;
children = (
E10231362BCF8A3C009D71FC /* Component */,
E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */,
);
path = ChannelLibraryView;
sourceTree = "<group>";
};
E10231502BCF8AF8009D71FC /* Components */ = {
isa = PBXGroup;
children = (
E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */,
);
path = Components;
sourceTree = "<group>";
};
E10231522BCF8AF8009D71FC /* ChannelLibraryView */ = {
isa = PBXGroup;
children = (
E10231502BCF8AF8009D71FC /* Components */,
E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */,
);
path = ChannelLibraryView;
sourceTree = "<group>";
};
E10231552BCF8AF8009D71FC /* Components */ = {
isa = PBXGroup;
children = (
E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */,
E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */,
);
path = Components;
sourceTree = "<group>";
};
E10231572BCF8AF8009D71FC /* ProgramsView */ = {
isa = PBXGroup;
children = (
E10231552BCF8AF8009D71FC /* Components */,
E10231562BCF8AF8009D71FC /* ProgramsView.swift */,
);
path = ProgramsView;
sourceTree = "<group>";
};
E102315D2BCF8B36009D71FC /* Components */ = {
isa = PBXGroup;
children = (
E103DF942BCF31CD000229B2 /* MediaItem.swift */,
);
path = Components;
sourceTree = "<group>";
};
E103DF912BCF2F1F000229B2 /* Components */ = { E103DF912BCF2F1F000229B2 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2076,8 +2156,8 @@
E103DF932BCF31C5000229B2 /* MediaView */ = { E103DF932BCF31C5000229B2 /* MediaView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E102315D2BCF8B36009D71FC /* Components */,
C4E508172703E8190045C9AB /* MediaView.swift */, C4E508172703E8190045C9AB /* MediaView.swift */,
E103DF942BCF31CD000229B2 /* MediaItem.swift */,
); );
path = MediaView; path = MediaView;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2228,16 +2308,15 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */,
E10231522BCF8AF8009D71FC /* ChannelLibraryView */,
53ABFDEA2679753200886593 /* ConnectToServerView.swift */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */,
E154967B296CBB1A00C4EF88 /* FontPickerView.swift */, E154967B296CBB1A00C4EF88 /* FontPickerView.swift */,
E1A42E4D28CBD3B200A14DCB /* HomeView */, E1A42E4D28CBD3B200A14DCB /* HomeView */,
E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */, E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */,
E193D54E271942C000900D82 /* ItemView */, E193D54E271942C000900D82 /* ItemView */,
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */,
C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */,
C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */,
E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */, E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */,
E103DF932BCF31C5000229B2 /* MediaView */, E103DF932BCF31C5000229B2 /* MediaView */,
E10231572BCF8AF8009D71FC /* ProgramsView */,
E1E1643928BAC2EF00323B0A /* SearchView.swift */, E1E1643928BAC2EF00323B0A /* SearchView.swift */,
E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54F2719430400900D82 /* ServerDetailView.swift */,
E193D54A271941D300900D82 /* ServerListView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */,
@ -2294,6 +2373,7 @@
E18E01F3288747580022598C /* AboutAppView.swift */, E18E01F3288747580022598C /* AboutAppView.swift */,
E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */,
E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */,
E10231382BCF8A3C009D71FC /* ChannelLibraryView */,
5338F74D263B61370014BF09 /* ConnectToServerView.swift */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */,
E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */,
E13332922953BA9400EE76AB /* DownloadTaskView */, E13332922953BA9400EE76AB /* DownloadTaskView */,
@ -2302,15 +2382,11 @@
E168BD07289A4162001A6922 /* HomeView */, E168BD07289A4162001A6922 /* HomeView */,
E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */,
E14F7D0A26DB3714007C3AE6 /* ItemView */, E14F7D0A26DB3714007C3AE6 /* ItemView */,
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */,
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */,
C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */,
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */,
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */,
E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, E170D104294D21FA0017224C /* MediaSourceInfoView.swift */,
E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */,
E103DF922BCF2F23000229B2 /* MediaView */, E103DF922BCF2F23000229B2 /* MediaView */,
E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */,
E10231342BCF8A3C009D71FC /* ProgramsView */,
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */,
53EE24E5265060780068F029 /* SearchView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */,
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
@ -2759,12 +2835,12 @@
E1AD105326D96F5A003E4A08 /* Components */ = { E1AD105326D96F5A003E4A08 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */,
E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */,
E18E0203288749200022598C /* BlurView.swift */, E18E0203288749200022598C /* BlurView.swift */,
E1153DCB2BBB633B00424D36 /* FastSVGView.swift */, E1153DCB2BBB633B00424D36 /* FastSVGView.swift */,
531AC8BE26750DE20091C7EB /* ImageView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */,
E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, E1D37F472B9C648E00343D2B /* MaxHeightText.swift */,
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
E1DC983F296DEBA500982F06 /* PosterIndicators */, E1DC983F296DEBA500982F06 /* PosterIndicators */,
E1FE69A628C29B720021BC93 /* ProgressBar.swift */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */,
E187A60129AB28F0008387E6 /* RotateContentView.swift */, E187A60129AB28F0008387E6 /* RotateContentView.swift */,
@ -3402,7 +3478,6 @@
E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */,
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */,
C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */,
E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */,
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
@ -3431,6 +3506,7 @@
E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, E187A60529AD2E25008387E6 /* StepperView.swift in Sources */,
E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */, E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
@ -3439,13 +3515,12 @@
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */,
E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */, E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */,
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
E1575EA1293E7B1E001665B1 /* String.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */,
E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */,
E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */,
@ -3454,13 +3529,14 @@
E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */, E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */,
E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */,
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */, E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */,
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */, E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */,
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */,
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
E1575E95293E7B1E001665B1 /* Font.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
@ -3489,7 +3565,6 @@
E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */,
E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */, E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */,
E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */, E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */,
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */,
E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */, E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */,
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */,
@ -3505,7 +3580,6 @@
E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */,
E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */,
E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */, E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */,
E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */,
E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */, E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */,
62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */,
62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
@ -3516,7 +3590,6 @@
E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */,
E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */,
E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */,
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */,
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
@ -3537,13 +3610,11 @@
E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */,
E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */,
E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */,
C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */,
E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */,
E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */,
E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */,
E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */,
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */,
E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */, E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */,
E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */,
@ -3569,7 +3640,6 @@
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */,
E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */,
C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */,
E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
@ -3580,7 +3650,9 @@
E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, E1575E72293E77B5001665B1 /* Utilities.swift in Sources */,
E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */,
E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */, E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */,
E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */,
E1E6C45129B104850064123F /* Button.swift in Sources */, E1E6C45129B104850064123F /* Button.swift in Sources */,
E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */,
E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */,
E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */, E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */,
E11042762B8013DF00821020 /* Stateful.swift in Sources */, E11042762B8013DF00821020 /* Stateful.swift in Sources */,
@ -3638,10 +3710,11 @@
E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */,
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */, E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */,
E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */,
E102315F2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */,
E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */, E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */,
E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */, E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */,
@ -3665,6 +3738,7 @@
E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */,
E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */, 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */,
E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */,
E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */,
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */,
@ -3676,19 +3750,20 @@
E193D547271941C500900D82 /* UserListView.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */,
E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */,
E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */,
E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */,
E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */,
E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */,
E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */,
E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */,
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */,
E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */,
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */,
E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */, E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */,
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */,
E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */, E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */,
E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */,
C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */,
E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */,
4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */, 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */,
E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
@ -3740,7 +3815,7 @@
E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */,
E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */,
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */,
@ -3778,7 +3853,6 @@
E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */,
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
E133328829538D8D00EE76AB /* Files.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */,
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */,
E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */,
@ -3795,16 +3869,15 @@
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */,
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */,
E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
E17FB55228C119D400311DFE /* Displayable.swift in Sources */, E17FB55228C119D400311DFE /* Displayable.swift in Sources */,
E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */,
E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */, E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */,
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */,
E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */,
C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */,
E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */,
E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */, E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */,
E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */,
@ -3818,7 +3891,6 @@
E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */,
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */,
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E0204288749200022598C /* RowDivider.swift in Sources */,
E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */,
@ -3839,12 +3911,9 @@
E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */,
E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */, E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */,
E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */,
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */,
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
6264E88C273850380081A12A /* Strings.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */,
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */,
E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */,
C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
@ -3853,6 +3922,7 @@
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */,
E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */,
@ -3880,6 +3950,7 @@
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */,
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */,
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */, E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */,
E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
@ -3894,6 +3965,7 @@
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */,
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
@ -3906,6 +3978,7 @@
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */,
@ -3916,8 +3989,10 @@
E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */,
E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */,
E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */,
E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */,
E113133228BDC72000930F75 /* FilterView.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */,
62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */,
E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */,
E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */, E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */,
E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */,
E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */, E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */,
@ -3941,6 +4016,7 @@
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */,
E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */,
E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */,
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */,
@ -3948,7 +4024,6 @@
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */,
C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */,
E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */,
E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */,
@ -3958,7 +4033,6 @@
E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */,
E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */,
E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */,
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */,
E129429328F2845000796AC6 /* SliderType.swift in Sources */, E129429328F2845000796AC6 /* SliderType.swift in Sources */,
E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */,
E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */, E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */,
@ -3982,7 +4056,6 @@
E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */, E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */,
E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */,
E18ACA952A15A3E100BB4F35 /* (null) in Sources */, E18ACA952A15A3E100BB4F35 /* (null) in Sources */,
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */,
E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */,
E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
@ -3999,10 +4072,10 @@
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */,
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */,
E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */, E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */,
E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */,
E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */,
C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */,
E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */,
E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */,
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */,
@ -4058,6 +4131,7 @@
E18E01EA288747230022598C /* MovieItemView.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */,
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
E148128528C15472003B8787 /* SortOrder.swift in Sources */, E148128528C15472003B8787 /* SortOrder.swift in Sources */,
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */,
E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */,

View File

@ -9,22 +9,28 @@
import Defaults import Defaults
import SwiftUI import SwiftUI
struct LandscapePosterProgressBar: View { // TODO: fix relative padding, or remove?
// TODO: gradient should grow/shrink with content, not relative to container
struct LandscapePosterProgressBar<Content: View>: View {
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
let title: String
let progress: CGFloat
// Scale padding depending on view width // Scale padding depending on view width
@State @State
private var paddingScale: CGFloat = 1.0 private var paddingScale: CGFloat = 1.0
@State @State
private var width: CGFloat = 0 private var width: CGFloat = 0
private let content: () -> Content
private let progress: Double
var body: some View { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
Color.clear
LinearGradient( LinearGradient(
stops: [ stops: [
.init(color: .clear, location: 0), .init(color: .clear, location: 0),
@ -37,11 +43,7 @@ struct LandscapePosterProgressBar: View {
VStack(alignment: .leading, spacing: 3 * paddingScale) { VStack(alignment: .leading, spacing: 3 * paddingScale) {
Spacer() content()
Text(title)
.font(.subheadline)
.foregroundColor(.white)
ProgressBar(progress: progress) ProgressBar(progress: progress)
.foregroundColor(accentColor) .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
)
}
}

View File

@ -11,6 +11,7 @@ import JellyfinAPI
import SwiftUI import SwiftUI
// TODO: expose `ImageView.image` modifier for image aspect fill/fit // TODO: expose `ImageView.image` modifier for image aspect fill/fit
// TODO: allow `content` to trigger `onSelect`?
struct PosterButton<Item: Poster>: View { struct PosterButton<Item: Poster>: View {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -93,7 +93,7 @@ extension PagingLibraryView {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(item.displayTitle) Text(item.displayTitle)
.font(posterType == .landscape ? .subheadline : .callout) .font(posterType == .landscape ? .subheadline : .callout)
.fontWeight(.regular) .fontWeight(.semibold)
.foregroundColor(.primary) .foregroundColor(.primary)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)

View File

@ -0,0 +1,53 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import 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)
}
}
}
}

View File

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

View File

@ -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<ProgramsViewModel, [ChannelProgram]>
) -> 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)
}
}
}
}

View File

@ -40,9 +40,6 @@ struct LiveNativeVideoPlayer: View {
.navigationBarHidden() .navigationBarHidden()
.statusBarHidden() .statusBarHidden()
.ignoresSafeArea() .ignoresSafeArea()
.onDisappear {
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
}
} }
} }

View File

@ -170,9 +170,6 @@ struct LiveVideoPlayer: View {
gestureStateHandler: gestureStateHandler, gestureStateHandler: gestureStateHandler,
updateViewProxy: updateViewProxy updateViewProxy: updateViewProxy
) )
.onDisappear {
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
}
} }
var body: some View { var body: some View {
@ -221,9 +218,6 @@ struct LiveVideoPlayer: View {
audioOffset = 0 audioOffset = 0
subtitleOffset = 0 subtitleOffset = 0
} }
.onDisappear {
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
}
} }
} }