Live TV Updates (#1022)
This commit is contained in:
parent
913dda5fea
commit
ec9bfaa2fe
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -8,8 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Replace scaling with size so that the Capsule corner radius
|
||||
// is not affected
|
||||
// TODO: see if animation is correct here or should be in caller views
|
||||
|
||||
struct ProgressBar: View {
|
||||
|
||||
|
@ -18,12 +17,15 @@ struct ProgressBar: View {
|
|||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.2)
|
||||
|
||||
Capsule()
|
||||
.scaleEffect(x: progress, y: 1, anchor: .leading)
|
||||
.mask(alignment: .leading) {
|
||||
Rectangle()
|
||||
.scaleEffect(x: progress, anchor: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.animation(.linear(duration: 0.1), value: progress)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,20 +8,28 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: is the background color setting really the best way?
|
||||
|
||||
struct SystemImageContentView: View {
|
||||
|
||||
@State
|
||||
private var contentSize: CGSize = .zero
|
||||
|
||||
private var backgroundColor: Color
|
||||
private var heightRatio: CGFloat
|
||||
private let systemName: String
|
||||
private var widthRatio: CGFloat
|
||||
|
||||
init(systemName: String?) {
|
||||
self.backgroundColor = Color.secondarySystemFill
|
||||
self.heightRatio = 3
|
||||
self.systemName = systemName ?? "circle"
|
||||
self.widthRatio = 3.5
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.secondarySystemFill
|
||||
backgroundColor
|
||||
.opacity(0.5)
|
||||
|
||||
Image(systemName: systemName)
|
||||
|
@ -29,8 +37,20 @@ struct SystemImageContentView: View {
|
|||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
.frame(width: contentSize.width / 3.5, height: contentSize.height / 3)
|
||||
.frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
|
||||
}
|
||||
.size($contentSize)
|
||||
}
|
||||
}
|
||||
|
||||
extension SystemImageContentView {
|
||||
|
||||
func background(color: Color = Color.secondarySystemFill) -> Self {
|
||||
copy(modifying: \.backgroundColor, with: color)
|
||||
}
|
||||
|
||||
func imageFrameRatio(width: CGFloat = 3.5, height: CGFloat = 3) -> Self {
|
||||
copy(modifying: \.heightRatio, with: height)
|
||||
.copy(modifying: \.widthRatio, with: width)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,8 +18,15 @@ final class LiveTVCoordinator: NavigationCoordinatable {
|
|||
@Root
|
||||
var start = makeStart
|
||||
|
||||
@Route(.push)
|
||||
var channels = makeChannels
|
||||
|
||||
func makeChannels() -> ChannelLibraryView {
|
||||
ChannelLibraryView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
LiveTVChannelsView()
|
||||
ProgramsView()
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,20 +38,22 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
|
|||
LiveNativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.supportedOrientations(UIDevice.isPhone ? .landscape : .allButUpsideDown)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.backport
|
||||
.persistentSystemOverlays(.hidden)
|
||||
|
||||
#else
|
||||
|
||||
PreferencesView {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
LiveVideoPlayer(manager: self.videoPlayerManager)
|
||||
} else {
|
||||
LiveNativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
#endif
|
||||
|
|
|
@ -23,10 +23,6 @@ final class MainCoordinator: NavigationCoordinatable {
|
|||
var mainTab = makeMainTab
|
||||
@Root
|
||||
var serverList = makeServerList
|
||||
@Root
|
||||
var liveTV = makeLiveTV
|
||||
// @Route(.fullScreen)
|
||||
// var videoPlayer = makeVideoPlayer
|
||||
|
||||
init() {
|
||||
|
||||
|
@ -65,12 +61,4 @@ final class MainCoordinator: NavigationCoordinatable {
|
|||
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
|
||||
NavigationViewCoordinator(ServerListCoordinator())
|
||||
}
|
||||
|
||||
func makeLiveTV() -> LiveTVTabCoordinator {
|
||||
LiveTVTabCoordinator()
|
||||
}
|
||||
|
||||
// func makeVideoPlayer(parameters: VideoPlayerCoordinator.Parameters) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
// NavigationViewCoordinator(VideoPlayerCoordinator(parameters: parameters))
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import Stinsen
|
|||
import SwiftUI
|
||||
|
||||
final class MainTabCoordinator: TabCoordinatable {
|
||||
|
||||
var child = TabChild(startingItems: [
|
||||
\MainTabCoordinator.home,
|
||||
\MainTabCoordinator.tvShows,
|
||||
|
|
|
@ -20,6 +20,8 @@ final class MediaCoordinator: NavigationCoordinatable {
|
|||
#if os(tvOS)
|
||||
@Route(.modal)
|
||||
var library = makeLibrary
|
||||
@Route(.modal)
|
||||
var liveTV = makeLiveTV
|
||||
#else
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
|
@ -33,21 +35,20 @@ final class MediaCoordinator: NavigationCoordinatable {
|
|||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
#else
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeLiveTV() -> LiveTVCoordinator {
|
||||
LiveTVCoordinator()
|
||||
}
|
||||
|
||||
func makeDownloads() -> DownloadListCoordinator {
|
||||
DownloadListCoordinator()
|
||||
}
|
||||
#endif
|
||||
|
||||
func makeLiveTV() -> LiveTVCoordinator {
|
||||
LiveTVCoordinator()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
MediaView()
|
||||
|
|
|
@ -49,8 +49,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable {
|
|||
.ignoresSafeArea()
|
||||
.backport
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.backport
|
||||
.defersSystemGestures(on: .all)
|
||||
|
||||
#else
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
|
|
|
@ -6,32 +6,36 @@
|
|||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class LiveTVChannelsCoordinator: NavigationCoordinatable {
|
||||
// TODO: add normal video player
|
||||
// TODO: replace current instances of video player on other coordinators, if able
|
||||
|
||||
let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start)
|
||||
/// A coordinator used on tvOS to present video players due to differences in view controller presentation.
|
||||
final class VideoPlayerWrapperCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \VideoPlayerWrapperCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.fullScreen)
|
||||
var liveVideoPlayer = makeLiveVideoPlayer
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
private let content: () -> any View
|
||||
|
||||
init(@ViewBuilder _ content: @escaping () -> any View) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator<LiveVideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager))
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
LiveTVChannelsView()
|
||||
private func makeStart() -> some View {
|
||||
content()
|
||||
.eraseToAnyView()
|
||||
}
|
||||
}
|
|
@ -46,14 +46,16 @@ extension BaseItemDto: Poster {
|
|||
|
||||
var typeSystemImage: String? {
|
||||
switch type {
|
||||
case .boxSet:
|
||||
"film.stack"
|
||||
case .episode, .movie, .series:
|
||||
"film"
|
||||
case .folder:
|
||||
"folder.fill"
|
||||
case .person:
|
||||
"person.fill"
|
||||
case .boxSet:
|
||||
"film.stack"
|
||||
case .program:
|
||||
"tv"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
@ -83,6 +85,8 @@ extension BaseItemDto: Poster {
|
|||
}
|
||||
case .folder:
|
||||
return [imageSource(.primary, maxWidth: maxWidth)]
|
||||
case .program:
|
||||
return [imageSource(.primary, maxWidth: maxWidth)]
|
||||
case .video:
|
||||
return [imageSource(.primary, maxWidth: maxWidth)]
|
||||
default:
|
||||
|
|
|
@ -12,6 +12,8 @@ import Foundation
|
|||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
// TODO: clean up
|
||||
|
||||
extension BaseItemDto: Displayable {
|
||||
|
||||
var displayTitle: String {
|
||||
|
@ -97,18 +99,27 @@ extension BaseItemDto {
|
|||
return " "
|
||||
}
|
||||
|
||||
func getLiveProgressPercentage() -> Double {
|
||||
if let startDate,
|
||||
let endDate
|
||||
{
|
||||
let start = startDate.timeIntervalSinceReferenceDate
|
||||
let end = endDate.timeIntervalSinceReferenceDate
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
let length = end - start
|
||||
let progress = now - start
|
||||
var programDuration: TimeInterval? {
|
||||
guard let startDate, let endDate else { return nil }
|
||||
return endDate.timeIntervalSince(startDate)
|
||||
}
|
||||
|
||||
var programProgress: Double? {
|
||||
guard let startDate, let endDate else { return nil }
|
||||
|
||||
let length = endDate.timeIntervalSince(startDate)
|
||||
let progress = Date.now.timeIntervalSince(startDate)
|
||||
|
||||
return progress / length
|
||||
}
|
||||
return 0
|
||||
|
||||
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] {
|
||||
|
|
|
@ -24,6 +24,27 @@ extension Sequence {
|
|||
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
|
||||
}
|
||||
|
||||
/// Returns the elements of the sequence, sorted by comparing values
|
||||
/// at the given `KeyPath` of `Element`.
|
||||
///
|
||||
/// `nil` values are considered the maximum.
|
||||
func sorted<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] {
|
||||
filter { !other.contains($0[keyPath: keyPath]) }
|
||||
}
|
||||
|
|
|
@ -81,7 +81,9 @@ extension String {
|
|||
.reduce("", +)
|
||||
}
|
||||
|
||||
static var emptyDash = "--"
|
||||
static let emptyDash = "--"
|
||||
|
||||
static let emptyTime = "--:--"
|
||||
|
||||
var shortFileName: String {
|
||||
(split(separator: "/").last?.description ?? self)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,6 +24,18 @@ protocol Poster: Displayable, Hashable, Identifiable {
|
|||
|
||||
extension Poster {
|
||||
|
||||
var showTitle: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
|
||||
.init()
|
||||
}
|
||||
|
||||
func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] {
|
||||
[]
|
||||
}
|
||||
|
||||
func cinematicPosterImageSources() -> [ImageSource] {
|
||||
[]
|
||||
}
|
||||
|
|
|
@ -9,9 +9,14 @@
|
|||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Rename to `PosterDisplayType` or `PosterDisplay`?
|
||||
// TODO: after no longer experimental, nest under `Poster`
|
||||
// TODO: Refactor to `ItemDisplayType`
|
||||
// - this is to move away from video specific to generalizing all media types. However,
|
||||
// media is still able to use grammar for their own contexts.
|
||||
// - move landscape/portrait to wide/narrow
|
||||
// - add `square`/something similar
|
||||
// TODO: after no longer experimental, nest under `Poster`?
|
||||
// tracker: https://github.com/apple/swift-evolution/blob/main/proposals/0404-nested-protocols.md
|
||||
|
||||
enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable {
|
||||
|
||||
case landscape
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)!
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,11 +12,9 @@ import JellyfinAPI
|
|||
class LiveVideoPlayerManager: VideoPlayerManager {
|
||||
|
||||
@Published
|
||||
var program: LiveTVChannelProgram?
|
||||
@Published
|
||||
var dateFormatter = DateFormatter()
|
||||
var program: ChannelProgram?
|
||||
|
||||
init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: LiveTVChannelProgram? = nil) {
|
||||
init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: ChannelProgram? = nil) {
|
||||
self.program = program
|
||||
super.init()
|
||||
|
||||
|
|
|
@ -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 ?? []
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@ import VLCUI
|
|||
// TODO: should view models handle progress reports instead, with a protocol
|
||||
// for other types of media handling
|
||||
|
||||
// TODO: transition to `Stateful`
|
||||
class VideoPlayerManager: ViewModel {
|
||||
|
||||
class CurrentProgressHandler: ObservableObject {
|
||||
|
|
|
@ -64,6 +64,7 @@ class VideoPlayerViewModel: ViewModel {
|
|||
return hlsStreamComponents.url!
|
||||
}
|
||||
|
||||
// TODO: should start time be from the media source instead?
|
||||
var vlcVideoPlayerConfiguration: VLCVideoPlayer.Configuration {
|
||||
let configuration = VLCVideoPlayer.Configuration(url: playbackURL)
|
||||
configuration.autoPlay = true
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -10,8 +10,13 @@ import SwiftUI
|
|||
|
||||
struct LandscapePosterProgressBar: View {
|
||||
|
||||
let title: String
|
||||
let progress: CGFloat
|
||||
private let title: String?
|
||||
private let progress: Double
|
||||
|
||||
init(title: String? = nil, progress: Double) {
|
||||
self.title = title
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
@ -26,15 +31,16 @@ struct LandscapePosterProgressBar: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
|
||||
if let title {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
ProgressBar(progress: progress)
|
||||
.frame(height: 5)
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.bottom, 7)
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -14,8 +14,6 @@ import SwiftUI
|
|||
|
||||
struct MediaView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var mainRouter: MainCoordinator.Router
|
||||
@EnvironmentObject
|
||||
private var router: MediaCoordinator.Router
|
||||
|
||||
|
@ -36,7 +34,8 @@ struct MediaView: View {
|
|||
filters: .default
|
||||
)
|
||||
router.route(to: \.library, viewModel)
|
||||
case .downloads: ()
|
||||
case .downloads:
|
||||
assertionFailure("Downloads unavailable on tvOS")
|
||||
case .favorites:
|
||||
let viewModel = ItemLibraryViewModel(
|
||||
title: L10n.favorites,
|
||||
|
@ -44,7 +43,7 @@ struct MediaView: View {
|
|||
)
|
||||
router.route(to: \.library, viewModel)
|
||||
case .liveTV:
|
||||
mainRouter.root(\.liveTV)
|
||||
router.route(to: \.liveTV)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -44,9 +44,6 @@ struct LiveNativeVideoPlayer: View {
|
|||
}
|
||||
.navigationBarHidden(true)
|
||||
.ignoresSafeArea()
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -53,14 +53,14 @@ extension LiveVideoPlayer.Overlay {
|
|||
var body: some View {
|
||||
VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) {
|
||||
|
||||
if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) {
|
||||
Text(subtitle.title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
.alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
dimensions[.leading]
|
||||
}
|
||||
}
|
||||
// if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) {
|
||||
// Text(subtitle.title)
|
||||
// .font(.subheadline)
|
||||
// .foregroundColor(.white)
|
||||
// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in
|
||||
// dimensions[.leading]
|
||||
// }
|
||||
// }
|
||||
|
||||
HStack {
|
||||
|
||||
|
|
|
@ -80,9 +80,6 @@ struct LiveVideoPlayer: View {
|
|||
.scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -149,15 +149,9 @@
|
|||
BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; };
|
||||
BD0BA22E2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; };
|
||||
BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; };
|
||||
C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; };
|
||||
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; };
|
||||
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; };
|
||||
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; };
|
||||
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; };
|
||||
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; };
|
||||
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; };
|
||||
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; };
|
||||
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
|
||||
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; };
|
||||
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; };
|
||||
C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */; };
|
||||
|
@ -174,27 +168,33 @@
|
|||
C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */; };
|
||||
C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; };
|
||||
C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */; };
|
||||
C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; };
|
||||
C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; };
|
||||
C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; };
|
||||
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; };
|
||||
C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; };
|
||||
C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; };
|
||||
C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */; };
|
||||
C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */; };
|
||||
C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */; };
|
||||
C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */; };
|
||||
C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; };
|
||||
C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */; };
|
||||
C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
|
||||
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; };
|
||||
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; };
|
||||
C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* MediaView.swift */; };
|
||||
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; };
|
||||
E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; };
|
||||
E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; };
|
||||
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; };
|
||||
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; };
|
||||
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */; };
|
||||
E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */; };
|
||||
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */; };
|
||||
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */; };
|
||||
E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231332BCF8A3C009D71FC /* ProgramsView.swift */; };
|
||||
E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */; };
|
||||
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */; };
|
||||
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; };
|
||||
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; };
|
||||
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */; };
|
||||
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */; };
|
||||
E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */; };
|
||||
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */; };
|
||||
E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */; };
|
||||
E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */; };
|
||||
E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102314F2BCF8AF8009D71FC /* WideChannelGridItem.swift */; };
|
||||
E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231512BCF8AF8009D71FC /* ChannelLibraryView.swift */; };
|
||||
E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231532BCF8AF8009D71FC /* ProgramButtonContent.swift */; };
|
||||
E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231542BCF8AF8009D71FC /* ProgramProgressOverlay.swift */; };
|
||||
E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231562BCF8AF8009D71FC /* ProgramsView.swift */; };
|
||||
E102315F2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */; };
|
||||
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */; };
|
||||
E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */; };
|
||||
E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; };
|
||||
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
|
||||
|
@ -563,7 +563,6 @@
|
|||
E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; };
|
||||
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
|
||||
E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
|
||||
E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; };
|
||||
E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; };
|
||||
E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; };
|
||||
E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.swift */; };
|
||||
|
@ -641,7 +640,6 @@
|
|||
E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; };
|
||||
E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */; };
|
||||
E1B90C8A2BC475E7007027C8 /* ScalingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */; };
|
||||
E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */; };
|
||||
E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; };
|
||||
E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; };
|
||||
E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */; };
|
||||
|
@ -834,7 +832,6 @@
|
|||
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -953,12 +950,9 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -973,21 +967,25 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1246,7 +1244,6 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1507,15 +1504,16 @@
|
|||
532175392671BCED005491E6 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */,
|
||||
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
|
||||
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
|
||||
E113133928BEB71D00930F75 /* FilterViewModel.swift */,
|
||||
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
|
||||
E107BB9127880A4000354E07 /* ItemViewModel */,
|
||||
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
|
||||
C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */,
|
||||
C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */,
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
|
||||
E1CAF65C2BA345830087D991 /* MediaViewModel */,
|
||||
E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */,
|
||||
6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */,
|
||||
62E632DB267D2E130063E547 /* SearchViewModel.swift */,
|
||||
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
|
||||
|
@ -1524,7 +1522,6 @@
|
|||
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */,
|
||||
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
|
||||
BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
|
||||
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
|
||||
625CB57B2678CE1000530A6E /* ViewModel.swift */,
|
||||
);
|
||||
|
@ -1613,6 +1610,7 @@
|
|||
children = (
|
||||
E1D4BF802719D22800A11E64 /* AppAppearance.swift */,
|
||||
E129429728F4785200796AC6 /* CaseIterablePicker.swift */,
|
||||
E10231432BCF8A51009D71FC /* ChannelProgram.swift */,
|
||||
E17FB55128C119D400311DFE /* Displayable.swift */,
|
||||
E1579EA62B97DC1500A31CA1 /* Eventful.swift */,
|
||||
E1092F4B29106F9F00163F57 /* GestureAction.swift */,
|
||||
|
@ -1642,7 +1640,6 @@
|
|||
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */,
|
||||
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
|
||||
E15756352936856700976E1F /* VideoPlayerType.swift */,
|
||||
C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */,
|
||||
);
|
||||
path = Objects;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1656,9 +1653,7 @@
|
|||
E1C92618288756BD002A7A66 /* DotHStack.swift */,
|
||||
E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */,
|
||||
E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */,
|
||||
E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */,
|
||||
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */,
|
||||
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */,
|
||||
E10E842B29A589860064EA49 /* NonePosterButton.swift */,
|
||||
E111D8F928D0400900400001 /* PagingLibraryView.swift */,
|
||||
E1C92617288756BD002A7A66 /* PosterButton.swift */,
|
||||
|
@ -1969,10 +1964,7 @@
|
|||
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
|
||||
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
|
||||
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */,
|
||||
C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */,
|
||||
C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */,
|
||||
C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */,
|
||||
C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */,
|
||||
E102312B2BCF8A08009D71FC /* LiveTVCoordinator */,
|
||||
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
|
||||
E193D5412719404B00900D82 /* MainCoordinator */,
|
||||
62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */,
|
||||
|
@ -1986,6 +1978,7 @@
|
|||
E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */,
|
||||
E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */,
|
||||
E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */,
|
||||
E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */,
|
||||
);
|
||||
path = Coordinators;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2056,6 +2049,93 @@
|
|||
path = Components;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2076,8 +2156,8 @@
|
|||
E103DF932BCF31C5000229B2 /* MediaView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E102315D2BCF8B36009D71FC /* Components */,
|
||||
C4E508172703E8190045C9AB /* MediaView.swift */,
|
||||
E103DF942BCF31CD000229B2 /* MediaItem.swift */,
|
||||
);
|
||||
path = MediaView;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2228,16 +2308,15 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */,
|
||||
E10231522BCF8AF8009D71FC /* ChannelLibraryView */,
|
||||
53ABFDEA2679753200886593 /* ConnectToServerView.swift */,
|
||||
E154967B296CBB1A00C4EF88 /* FontPickerView.swift */,
|
||||
E1A42E4D28CBD3B200A14DCB /* HomeView */,
|
||||
E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */,
|
||||
E193D54E271942C000900D82 /* ItemView */,
|
||||
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */,
|
||||
C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */,
|
||||
C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */,
|
||||
E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */,
|
||||
E103DF932BCF31C5000229B2 /* MediaView */,
|
||||
E10231572BCF8AF8009D71FC /* ProgramsView */,
|
||||
E1E1643928BAC2EF00323B0A /* SearchView.swift */,
|
||||
E193D54F2719430400900D82 /* ServerDetailView.swift */,
|
||||
E193D54A271941D300900D82 /* ServerListView.swift */,
|
||||
|
@ -2294,6 +2373,7 @@
|
|||
E18E01F3288747580022598C /* AboutAppView.swift */,
|
||||
E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */,
|
||||
E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */,
|
||||
E10231382BCF8A3C009D71FC /* ChannelLibraryView */,
|
||||
5338F74D263B61370014BF09 /* ConnectToServerView.swift */,
|
||||
E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */,
|
||||
E13332922953BA9400EE76AB /* DownloadTaskView */,
|
||||
|
@ -2302,15 +2382,11 @@
|
|||
E168BD07289A4162001A6922 /* HomeView */,
|
||||
E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */,
|
||||
E14F7D0A26DB3714007C3AE6 /* ItemView */,
|
||||
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */,
|
||||
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */,
|
||||
C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */,
|
||||
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */,
|
||||
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */,
|
||||
E170D104294D21FA0017224C /* MediaSourceInfoView.swift */,
|
||||
E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */,
|
||||
E103DF922BCF2F23000229B2 /* MediaView */,
|
||||
E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */,
|
||||
E10231342BCF8A3C009D71FC /* ProgramsView */,
|
||||
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */,
|
||||
53EE24E5265060780068F029 /* SearchView.swift */,
|
||||
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
|
||||
|
@ -2759,12 +2835,12 @@
|
|||
E1AD105326D96F5A003E4A08 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */,
|
||||
E104DC952B9E7E29008F506D /* AssertionFailureView.swift */,
|
||||
E18E0203288749200022598C /* BlurView.swift */,
|
||||
E1153DCB2BBB633B00424D36 /* FastSVGView.swift */,
|
||||
531AC8BE26750DE20091C7EB /* ImageView.swift */,
|
||||
E1D37F472B9C648E00343D2B /* MaxHeightText.swift */,
|
||||
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
|
||||
E1DC983F296DEBA500982F06 /* PosterIndicators */,
|
||||
E1FE69A628C29B720021BC93 /* ProgressBar.swift */,
|
||||
E187A60129AB28F0008387E6 /* RotateContentView.swift */,
|
||||
|
@ -3402,7 +3478,6 @@
|
|||
E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */,
|
||||
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
|
||||
E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */,
|
||||
C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */,
|
||||
E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
|
||||
E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */,
|
||||
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
|
||||
|
@ -3431,6 +3506,7 @@
|
|||
E187A60529AD2E25008387E6 /* StepperView.swift in Sources */,
|
||||
E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */,
|
||||
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
|
||||
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
|
||||
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
|
||||
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
|
||||
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
|
||||
|
@ -3439,13 +3515,12 @@
|
|||
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
|
||||
E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */,
|
||||
E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */,
|
||||
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
||||
E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
|
||||
E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
|
||||
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
|
||||
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
|
||||
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
|
||||
C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
|
||||
C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
|
||||
E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */,
|
||||
E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */,
|
||||
E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */,
|
||||
|
@ -3454,13 +3529,14 @@
|
|||
E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */,
|
||||
E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */,
|
||||
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
|
||||
E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */,
|
||||
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
|
||||
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
|
||||
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
|
||||
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
|
||||
E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
|
||||
E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */,
|
||||
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
|
||||
E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */,
|
||||
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
|
||||
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
|
||||
E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
|
||||
|
@ -3489,7 +3565,6 @@
|
|||
E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */,
|
||||
E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */,
|
||||
E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */,
|
||||
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */,
|
||||
E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
|
||||
E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */,
|
||||
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */,
|
||||
|
@ -3505,7 +3580,6 @@
|
|||
E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */,
|
||||
E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */,
|
||||
E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */,
|
||||
E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */,
|
||||
E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */,
|
||||
62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */,
|
||||
62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
|
||||
|
@ -3516,7 +3590,6 @@
|
|||
E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */,
|
||||
E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */,
|
||||
E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */,
|
||||
C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */,
|
||||
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
|
||||
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
|
||||
E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
|
||||
|
@ -3537,13 +3610,11 @@
|
|||
E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */,
|
||||
E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */,
|
||||
E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */,
|
||||
C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */,
|
||||
E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */,
|
||||
E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */,
|
||||
E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */,
|
||||
E10E842A29A587110064EA49 /* LoadingView.swift in Sources */,
|
||||
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
|
||||
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */,
|
||||
E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */,
|
||||
E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
|
||||
E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */,
|
||||
|
@ -3569,7 +3640,6 @@
|
|||
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
|
||||
E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */,
|
||||
E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */,
|
||||
C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
|
||||
E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */,
|
||||
E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
|
||||
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
|
||||
|
@ -3580,7 +3650,9 @@
|
|||
E1575E72293E77B5001665B1 /* Utilities.swift in Sources */,
|
||||
E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */,
|
||||
E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */,
|
||||
E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */,
|
||||
E1E6C45129B104850064123F /* Button.swift in Sources */,
|
||||
E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */,
|
||||
E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */,
|
||||
E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */,
|
||||
E11042762B8013DF00821020 /* Stateful.swift in Sources */,
|
||||
|
@ -3638,10 +3710,11 @@
|
|||
E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
|
||||
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
|
||||
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
|
||||
C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */,
|
||||
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
|
||||
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
|
||||
E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */,
|
||||
E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */,
|
||||
E102315F2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
|
||||
E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */,
|
||||
E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */,
|
||||
E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */,
|
||||
|
@ -3665,6 +3738,7 @@
|
|||
E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */,
|
||||
E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
|
||||
535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */,
|
||||
E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */,
|
||||
E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */,
|
||||
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
|
||||
E1575E9A293E7B1E001665B1 /* Array.swift in Sources */,
|
||||
|
@ -3676,19 +3750,20 @@
|
|||
E193D547271941C500900D82 /* UserListView.swift in Sources */,
|
||||
E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */,
|
||||
E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */,
|
||||
E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */,
|
||||
E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */,
|
||||
E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */,
|
||||
E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */,
|
||||
E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */,
|
||||
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */,
|
||||
E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */,
|
||||
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
|
||||
5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
|
||||
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */,
|
||||
E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */,
|
||||
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */,
|
||||
E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */,
|
||||
E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */,
|
||||
C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */,
|
||||
E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */,
|
||||
4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */,
|
||||
E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
|
||||
|
@ -3740,7 +3815,7 @@
|
|||
E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */,
|
||||
E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
|
||||
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */,
|
||||
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */,
|
||||
E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
||||
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
|
||||
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
|
||||
E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */,
|
||||
|
@ -3778,7 +3853,6 @@
|
|||
E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */,
|
||||
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
|
||||
E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
|
||||
C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
|
||||
E133328829538D8D00EE76AB /* Files.swift in Sources */,
|
||||
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */,
|
||||
E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */,
|
||||
|
@ -3795,16 +3869,15 @@
|
|||
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
|
||||
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
|
||||
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
|
||||
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */,
|
||||
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */,
|
||||
E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
|
||||
E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
|
||||
E17FB55228C119D400311DFE /* Displayable.swift in Sources */,
|
||||
E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */,
|
||||
E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */,
|
||||
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */,
|
||||
E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */,
|
||||
C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */,
|
||||
E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */,
|
||||
E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */,
|
||||
E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */,
|
||||
|
@ -3818,7 +3891,6 @@
|
|||
E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */,
|
||||
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
|
||||
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
|
||||
C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */,
|
||||
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
|
||||
E18E0204288749200022598C /* RowDivider.swift in Sources */,
|
||||
E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */,
|
||||
|
@ -3839,12 +3911,9 @@
|
|||
E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */,
|
||||
E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */,
|
||||
E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */,
|
||||
C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */,
|
||||
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
|
||||
6264E88C273850380081A12A /* Strings.swift in Sources */,
|
||||
C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */,
|
||||
E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */,
|
||||
C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */,
|
||||
E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
|
||||
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
|
||||
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
|
||||
|
@ -3853,6 +3922,7 @@
|
|||
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
|
||||
E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */,
|
||||
E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
|
||||
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
|
||||
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
|
||||
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
|
||||
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */,
|
||||
|
@ -3880,6 +3950,7 @@
|
|||
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */,
|
||||
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
|
||||
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
|
||||
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */,
|
||||
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
|
||||
E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */,
|
||||
E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
|
||||
|
@ -3894,6 +3965,7 @@
|
|||
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
|
||||
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
||||
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
||||
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */,
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
|
||||
E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
|
||||
|
@ -3906,6 +3978,7 @@
|
|||
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
|
||||
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
|
||||
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
|
||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
|
||||
E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */,
|
||||
|
@ -3916,8 +3989,10 @@
|
|||
E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */,
|
||||
E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */,
|
||||
E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */,
|
||||
E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */,
|
||||
E113133228BDC72000930F75 /* FilterView.swift in Sources */,
|
||||
62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */,
|
||||
E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */,
|
||||
E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */,
|
||||
E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */,
|
||||
E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */,
|
||||
|
@ -3941,6 +4016,7 @@
|
|||
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
|
||||
E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */,
|
||||
E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
|
||||
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */,
|
||||
E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */,
|
||||
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
|
||||
E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */,
|
||||
|
@ -3948,7 +4024,6 @@
|
|||
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
|
||||
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
|
||||
E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */,
|
||||
C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */,
|
||||
E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */,
|
||||
E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */,
|
||||
E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */,
|
||||
|
@ -3958,7 +4033,6 @@
|
|||
E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */,
|
||||
E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */,
|
||||
E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */,
|
||||
C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */,
|
||||
E129429328F2845000796AC6 /* SliderType.swift in Sources */,
|
||||
E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */,
|
||||
E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */,
|
||||
|
@ -3982,7 +4056,6 @@
|
|||
E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */,
|
||||
E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */,
|
||||
E18ACA952A15A3E100BB4F35 /* (null) in Sources */,
|
||||
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */,
|
||||
E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */,
|
||||
E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
|
||||
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
|
||||
|
@ -3999,10 +4072,10 @@
|
|||
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
|
||||
E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */,
|
||||
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
|
||||
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */,
|
||||
E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */,
|
||||
E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */,
|
||||
E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */,
|
||||
C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */,
|
||||
E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */,
|
||||
E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */,
|
||||
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */,
|
||||
|
@ -4058,6 +4131,7 @@
|
|||
E18E01EA288747230022598C /* MovieItemView.swift in Sources */,
|
||||
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
|
||||
E148128528C15472003B8787 /* SortOrder.swift in Sources */,
|
||||
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
|
||||
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
|
||||
E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */,
|
||||
E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */,
|
||||
|
|
|
@ -9,22 +9,28 @@
|
|||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct LandscapePosterProgressBar: View {
|
||||
// TODO: fix relative padding, or remove?
|
||||
// TODO: gradient should grow/shrink with content, not relative to container
|
||||
|
||||
struct LandscapePosterProgressBar<Content: View>: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
let title: String
|
||||
let progress: CGFloat
|
||||
|
||||
// Scale padding depending on view width
|
||||
@State
|
||||
private var paddingScale: CGFloat = 1.0
|
||||
@State
|
||||
private var width: CGFloat = 0
|
||||
|
||||
private let content: () -> Content
|
||||
private let progress: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
Color.clear
|
||||
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
|
@ -37,11 +43,7 @@ struct LandscapePosterProgressBar: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 3 * paddingScale) {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
content()
|
||||
|
||||
ProgressBar(progress: progress)
|
||||
.foregroundColor(accentColor)
|
||||
|
@ -55,3 +57,43 @@ struct LandscapePosterProgressBar: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LandscapePosterProgressBar where Content == Text {
|
||||
|
||||
init(
|
||||
title: String,
|
||||
progress: Double
|
||||
) {
|
||||
self.init(
|
||||
content: {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
},
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LandscapePosterProgressBar where Content == EmptyView {
|
||||
|
||||
init(progress: Double) {
|
||||
self.init(
|
||||
content: { EmptyView() },
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LandscapePosterProgressBar {
|
||||
|
||||
init(
|
||||
progress: Double,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.init(
|
||||
content: content,
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import JellyfinAPI
|
|||
import SwiftUI
|
||||
|
||||
// TODO: expose `ImageView.image` modifier for image aspect fill/fit
|
||||
// TODO: allow `content` to trigger `onSelect`?
|
||||
|
||||
struct PosterButton<Item: Poster>: View {
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
//// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -93,7 +93,7 @@ extension PagingLibraryView {
|
|||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.displayTitle)
|
||||
.font(posterType == .landscape ? .subheadline : .callout)
|
||||
.fontWeight(.regular)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,9 +40,6 @@ struct LiveNativeVideoPlayer: View {
|
|||
.navigationBarHidden()
|
||||
.statusBarHidden()
|
||||
.ignoresSafeArea()
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -170,9 +170,6 @@ struct LiveVideoPlayer: View {
|
|||
gestureStateHandler: gestureStateHandler,
|
||||
updateViewProxy: updateViewProxy
|
||||
)
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -221,9 +218,6 @@ struct LiveVideoPlayer: View {
|
|||
audioOffset = 0
|
||||
subtitleOffset = 0
|
||||
}
|
||||
.onDisappear {
|
||||
NotificationCenter.default.post(name: .livePlayerDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue