From fdd1cdc15b0c2d896f2a926e575edd4eb112f75e Mon Sep 17 00:00:00 2001 From: Ashik K Date: Fri, 17 Oct 2025 19:23:53 +0200 Subject: [PATCH] Implement Electronic Program Guide (EPG) for Live TV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Full EPG grid with channels and time slots - 12-hour program window with auto-refresh every 5 minutes - Duration-based cell widths (3px/min) - Live program highlighting with progress bars - Current time indicator (red line) - Direct channel playback from guide - Auto-scroll to currently airing programs Changes: - Add EPGViewModel for data fetching and state management - Add EPGProgramCell, EPGChannelRow, EPGTimelineHeader, EPGCurrentTimeIndicator components - Update ProgramGuideView with complete EPG implementation - Make Channels default Live TV tab (was Program Guide) - Fix channel images in EPG to match Channels view display - Fix Live TV playback crash (audioStreams array bounds check) - Apply dark pink background throughout app Slash Commands: - Add /init-dev - Initialize dev session with project context - Add /sim - Build and launch in Apple TV simulator 🤖 Generated with Claude Code Co-Authored-By: Claude --- .claude/commands/init-dev.md | 16 ++ .claude/commands/sim.md | 14 ++ .../tvOSLiveTVCoordinator.swift | 15 +- Shared/ViewModels/EPGViewModel.swift | 138 +++++++++++++++++ Shared/ViewModels/VideoPlayerViewModel.swift | 2 +- .../Components/SplitFormWindowView.swift | 21 ++- .../ChannelLibraryView.swift | 3 + jellypig tvOS/Views/HomeView/HomeView.swift | 4 +- jellypig tvOS/Views/MediaView/MediaView.swift | 4 +- .../PagingLibraryView/PagingLibraryView.swift | 3 +- jellypig tvOS/Views/ProgramGuideView.swift | 142 ++++++++++++++++++ .../Components/EPGChannelRow.swift | 95 ++++++++++++ .../Components/EPGCurrentTimeIndicator.swift | 64 ++++++++ .../Components/EPGProgramCell.swift | 120 +++++++++++++++ .../Components/EPGTimelineHeader.swift | 66 ++++++++ .../Views/ProgramsView/ProgramsView.swift | 3 + jellypig tvOS/Views/SearchView.swift | 3 + jellypig.xcodeproj/project.pbxproj | 72 +++++++++ 18 files changed, 764 insertions(+), 21 deletions(-) create mode 100644 .claude/commands/init-dev.md create mode 100644 .claude/commands/sim.md create mode 100644 Shared/ViewModels/EPGViewModel.swift create mode 100644 jellypig tvOS/Views/ProgramGuideView.swift create mode 100644 jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift create mode 100644 jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift create mode 100644 jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift create mode 100644 jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift diff --git a/.claude/commands/init-dev.md b/.claude/commands/init-dev.md new file mode 100644 index 00000000..6fe79fc1 --- /dev/null +++ b/.claude/commands/init-dev.md @@ -0,0 +1,16 @@ +--- +description: Initialize development session by reading project context and displaying available commands +--- + +Read the chats-summary.txt file from the parent directory (/Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt) to understand the project context, then display a quick summary of what you can help with. + +Steps: +1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt +2. Display a concise summary including: + - Project name and description + - Available custom slash commands (/sim, etc.) + - Recent features implemented + - Key configuration details + - Common tasks you can help with + +Make the output brief and actionable - focus on what's immediately useful for the developer. diff --git a/.claude/commands/sim.md b/.claude/commands/sim.md new file mode 100644 index 00000000..7be1c180 --- /dev/null +++ b/.claude/commands/sim.md @@ -0,0 +1,14 @@ +--- +description: Build jellypig tvOS and launch in Apple TV simulator +--- + +Build the latest version of jellypig tvOS in Debug configuration, install it on the Apple TV simulator, and launch it. + +Steps: +1. Boot the Apple TV simulator (16A71179-729D-4F1B-8698-8371F137025B) +2. Open Simulator.app +3. Build the project for tvOS Simulator +4. Install the built app on the simulator +5. Launch the app with bundle identifier org.ashik.jellypig + +Use xcodebuild to build, xcrun simctl to manage the simulator, and report success when the app is running. diff --git a/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift index 294146d2..e5f84a42 100644 --- a/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift +++ b/Shared/Coordinators/LiveTVCoordinator/tvOSLiveTVCoordinator.swift @@ -13,24 +13,25 @@ import SwiftUI final class LiveTVCoordinator: TabCoordinatable { var child = TabChild(startingItems: [ - \LiveTVCoordinator.programs, \LiveTVCoordinator.channels, + \LiveTVCoordinator.programGuide, ]) - @Route(tabItem: makeProgramsTab) - var programs = makePrograms + @Route(tabItem: makeProgramGuideTab) + var programGuide = makeProgramGuide + @Route(tabItem: makeChannelsTab) var channels = makeChannels - func makePrograms() -> VideoPlayerWrapperCoordinator { + func makeProgramGuide() -> VideoPlayerWrapperCoordinator { VideoPlayerWrapperCoordinator { - ProgramsView() + ProgramGuideView() } } @ViewBuilder - func makeProgramsTab(isActive: Bool) -> some View { - Label(L10n.programs, systemImage: "tv") + func makeProgramGuideTab(isActive: Bool) -> some View { + Label("Guide", systemImage: "list.bullet.rectangle") } func makeChannels() -> VideoPlayerWrapperCoordinator { diff --git a/Shared/ViewModels/EPGViewModel.swift b/Shared/ViewModels/EPGViewModel.swift new file mode 100644 index 00000000..16de3153 --- /dev/null +++ b/Shared/ViewModels/EPGViewModel.swift @@ -0,0 +1,138 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI +import SwiftUI + +final class EPGViewModel: ViewModel, Stateful { + + enum Action: Equatable { + case refresh + } + + enum BackgroundState: Hashable { + case refresh + } + + enum State: Hashable { + case initial + case refreshing + case content + case error(JellyfinAPIError) + } + + @Published + var channelPrograms: [ChannelProgram] = [] + + @Published + var state: State = .initial + + @Published + var backgroundStates: Set = [] + + // Time window configuration + var timeWindowStart: Date { + Calendar.current.date(byAdding: .hour, value: -1, to: .now) ?? .now + } + + var timeWindowEnd: Date { + Calendar.current.date(byAdding: .hour, value: 12, to: .now) ?? .now + } + + // Auto-refresh timer + private var refreshTimer: Timer? + private let refreshInterval: TimeInterval = 300 // 5 minutes + + override init() { + super.init() + setupRefreshTimer() + } + + deinit { + refreshTimer?.invalidate() + } + + func respond(to action: Action) -> State { + switch action { + case .refresh: + Task { + await fetchChannelsAndPrograms() + } + return .refreshing + } + } + + func fetchChannelsAndPrograms() async { + do { + state = .refreshing + + // Fetch all channels + var channelParameters = Paths.GetLiveTvChannelsParameters() + channelParameters.fields = .MinimumFields + channelParameters.userID = userSession.user.id + channelParameters.sortBy = [ItemSortBy.name] + // No limit - fetch all channels for EPG + + let channelRequest = Paths.getLiveTvChannels(parameters: channelParameters) + let channelResponse = try await userSession.client.send(channelRequest) + + guard let channels = channelResponse.value.items, !channels.isEmpty else { + state = .content + return + } + + // Fetch programs for all channels in time window + var programParameters = Paths.GetLiveTvProgramsParameters() + programParameters.channelIDs = channels.compactMap(\.id) + programParameters.userID = userSession.user.id + programParameters.minEndDate = timeWindowStart + programParameters.maxStartDate = timeWindowEnd + programParameters.sortBy = [ItemSortBy.startDate] + programParameters.fields = .MinimumFields + + let programRequest = Paths.getLiveTvPrograms(parameters: programParameters) + let programResponse = try await userSession.client.send(programRequest) + + // Group programs by channel + let groupedPrograms = (programResponse.value.items ?? []) + .grouped { program in + channels.first(where: { $0.id == program.channelID }) + } + + // Create ChannelProgram objects + let programs: [ChannelProgram] = channels + .reduce(into: [:]) { partialResult, channel in + partialResult[channel] = (groupedPrograms[channel] ?? []) + .sorted(using: \.startDate) + } + .map(ChannelProgram.init) + .sorted(using: \.channel.name) + + await MainActor.run { + self.channelPrograms = programs + self.state = .content + } + + } catch { + await MainActor.run { + self.state = .error(JellyfinAPIError(error.localizedDescription)) + } + } + } + + private func setupRefreshTimer() { + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in + guard let self = self else { return } + Task { + await self.fetchChannelsAndPrograms() + } + } + } +} diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index e5b81078..0b66230c 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -66,7 +66,7 @@ final class VideoPlayerViewModel: ViewModel { let configuration = VLCVideoPlayer.Configuration(url: playbackURL) configuration.autoPlay = true configuration.startTime = .seconds(max(0, item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset])) - if self.audioStreams[0].path != nil { + if !self.audioStreams.isEmpty, self.audioStreams[0].path != nil { configuration.audioIndex = .absolute(selectedAudioStreamIndex) } configuration.subtitleIndex = .absolute(selectedSubtitleStreamIndex) diff --git a/jellypig tvOS/Components/SplitFormWindowView.swift b/jellypig tvOS/Components/SplitFormWindowView.swift index e434030d..eb4f9619 100644 --- a/jellypig tvOS/Components/SplitFormWindowView.swift +++ b/jellypig tvOS/Components/SplitFormWindowView.swift @@ -14,18 +14,23 @@ struct SplitFormWindowView: View { private var descriptionView: () -> any View var body: some View { - HStack { + ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() - descriptionView() - .eraseToAnyView() - .frame(maxWidth: .infinity) + HStack { - Form { - contentView() + descriptionView() .eraseToAnyView() + .frame(maxWidth: .infinity) + + Form { + contentView() + .eraseToAnyView() + } + .padding(.top) + .scrollClipDisabled() } - .padding(.top) - .scrollClipDisabled() } } } diff --git a/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift index 3fe78240..09037cf3 100644 --- a/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ b/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -41,6 +41,9 @@ struct ChannelLibraryView: View { var body: some View { ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() + switch viewModel.state { case .content: if viewModel.elements.isEmpty { diff --git a/jellypig tvOS/Views/HomeView/HomeView.swift b/jellypig tvOS/Views/HomeView/HomeView.swift index 50341c11..c702c019 100644 --- a/jellypig tvOS/Views/HomeView/HomeView.swift +++ b/jellypig tvOS/Views/HomeView/HomeView.swift @@ -53,8 +53,8 @@ struct HomeView: View { var body: some View { ZStack { - // This keeps the ErrorView vertically aligned with the PagingLibraryView - Color.clear + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() switch viewModel.state { case .content: diff --git a/jellypig tvOS/Views/MediaView/MediaView.swift b/jellypig tvOS/Views/MediaView/MediaView.swift index 2d3cbc2a..280db700 100644 --- a/jellypig tvOS/Views/MediaView/MediaView.swift +++ b/jellypig tvOS/Views/MediaView/MediaView.swift @@ -53,8 +53,8 @@ struct MediaView: View { var body: some View { ZStack { - // This keeps the ErrorView vertically aligned with the PagingLibraryView - Color.clear + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() switch viewModel.state { case .content: diff --git a/jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift index d161cb11..6fc6b50f 100644 --- a/jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -349,7 +349,8 @@ struct PagingLibraryView: View { var body: some View { ZStack { - Color.clear + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() if cinematicBackground { CinematicBackgroundView(viewModel: cinematicBackgroundViewModel) diff --git a/jellypig tvOS/Views/ProgramGuideView.swift b/jellypig tvOS/Views/ProgramGuideView.swift new file mode 100644 index 00000000..8ac9f795 --- /dev/null +++ b/jellypig tvOS/Views/ProgramGuideView.swift @@ -0,0 +1,142 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ProgramGuideView: View { + + @StateObject + private var viewModel = EPGViewModel() + + // Configuration + private let pixelsPerMinute: CGFloat = 3.0 + + @State + private var currentTime = Date.now + + private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() + + content + } + .onAppear { + viewModel.send(.refresh) + } + .onReceive(timer) { _ in + currentTime = Date.now + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .initial: + ProgressView() + .scaleEffect(1.5) + + case .refreshing: + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + + Text("Loading Program Guide...") + .font(.headline) + .foregroundColor(.secondary) + } + + case .content: + if viewModel.channelPrograms.isEmpty { + emptyView + } else { + epgGridView + } + + case let .error(error): + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Error Loading Guide") + .font(.title2) + .fontWeight(.semibold) + + Text(error.localizedDescription) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 100) + + Button("Retry") { + viewModel.send(.refresh) + } + .buttonStyle(.card) + } + } + } + + private var epgGridView: some View { + ZStack { + VStack(spacing: 0) { + // Timeline header + EPGTimelineHeader( + timeWindowStart: viewModel.timeWindowStart, + timeWindowEnd: viewModel.timeWindowEnd, + pixelsPerMinute: pixelsPerMinute + ) + + Divider() + + // Channel rows + ScrollView(.vertical, showsIndicators: true) { + VStack(spacing: 0) { + ForEach(viewModel.channelPrograms, id: \.id) { channelProgram in + EPGChannelRow( + channelProgram: channelProgram, + timeWindowStart: viewModel.timeWindowStart, + timeWindowEnd: viewModel.timeWindowEnd, + pixelsPerMinute: pixelsPerMinute + ) + + Divider() + } + } + } + } + + // Current time indicator overlay + EPGCurrentTimeIndicator( + timeWindowStart: viewModel.timeWindowStart, + pixelsPerMinute: pixelsPerMinute + ) + } + .navigationTitle("Program Guide") + } + + private var emptyView: some View { + VStack(spacing: 20) { + Image(systemName: "tv") + .font(.system(size: 80)) + .foregroundColor(.secondary) + + Text("No Channels Available") + .font(.title) + .fontWeight(.semibold) + + Text("Check your Live TV setup in Jellyfin") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 100) + } + } +} diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift b/jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift new file mode 100644 index 00000000..6f3a6e84 --- /dev/null +++ b/jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift @@ -0,0 +1,95 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EPGChannelRow: View { + + let channelProgram: ChannelProgram + let timeWindowStart: Date + let timeWindowEnd: Date + let pixelsPerMinute: CGFloat + + private let channelColumnWidth: CGFloat = 200 + + var body: some View { + HStack(spacing: 0) { + // Channel info column (fixed width on left) + channelInfoView + .frame(width: channelColumnWidth) + + // Programs timeline + programsTimeline + } + .frame(height: 120) + } + + private var channelInfoView: some View { + VStack(spacing: 8) { + ZStack { + Color.clear + + ImageView(channelProgram.portraitImageSources(maxWidth: 80)) + .image { + $0.aspectRatio(contentMode: .fit) + } + .failure { + SystemImageContentView(systemName: channelProgram.systemImage, ratio: 0.66) + .background(color: .clear) + } + .placeholder { _ in + EmptyView() + } + } + .frame(width: 80, height: 80) + .aspectRatio(1.0, contentMode: .fit) + + Text(channelProgram.displayTitle) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 8) + } + + private var programsTimeline: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(channelProgram.programs, id: \.id) { program in + if let startDate = program.startDate, + let endDate = program.endDate + { + + let duration = endDate.timeIntervalSince(startDate) / 60 // minutes + let cellWidth = CGFloat(duration) * pixelsPerMinute + + let isCurrentlyAiring = (startDate ... endDate).contains(Date.now) + + EPGProgramCell( + program: program, + channel: channelProgram.channel, + cellWidth: max(cellWidth, 150), // Minimum width for readability + isCurrentlyAiring: isCurrentlyAiring + ) + .id(program.id) + } + } + } + .padding(.horizontal, 8) + } + .onAppear { + // Scroll to currently airing program + if let currentProgram = channelProgram.currentProgram { + proxy.scrollTo(currentProgram.id, anchor: .leading) + } + } + } + } +} diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift b/jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift new file mode 100644 index 00000000..fd9035b0 --- /dev/null +++ b/jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift @@ -0,0 +1,64 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct EPGCurrentTimeIndicator: View { + + let timeWindowStart: Date + let pixelsPerMinute: CGFloat + let channelColumnWidth: CGFloat = 200 + + @State + private var currentTime = Date.now + + private let timer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + var body: some View { + GeometryReader { geometry in + if currentTime >= timeWindowStart { + let offsetMinutes = currentTime.timeIntervalSince(timeWindowStart) / 60 + let xPosition = channelColumnWidth + (CGFloat(offsetMinutes) * pixelsPerMinute) + + if xPosition >= channelColumnWidth && xPosition <= geometry.size.width { + VStack(spacing: 0) { + // Time marker at top + ZStack { + Circle() + .fill(Color.red) + .frame(width: 20, height: 20) + + Text(currentTime, style: .time) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.red) + ) + .offset(y: -25) + } + .frame(height: 50) + + // Vertical line + Rectangle() + .fill(Color.red) + .frame(width: 2) + } + .offset(x: xPosition) + } + } + } + .allowsHitTesting(false) // Allow interactions to pass through + .onReceive(timer) { _ in + currentTime = Date.now + } + } +} diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift b/jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift new file mode 100644 index 00000000..527e9b91 --- /dev/null +++ b/jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift @@ -0,0 +1,120 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EPGProgramCell: View { + + @EnvironmentObject + private var router: VideoPlayerWrapperCoordinator.Router + + @Environment(\.isFocused) + private var isFocused + + let program: BaseItemDto + let channel: BaseItemDto + let cellWidth: CGFloat + let isCurrentlyAiring: Bool + + @State + private var currentTime = Date.now + + private let timer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + var body: some View { + Button { + handleSelection() + } label: { + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(borderColor, lineWidth: isFocused ? 4 : 2) + ) + + // Progress indicator for currently airing programs + if isCurrentlyAiring, let progress = program.programProgress(relativeTo: currentTime) { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.3)) + .frame(width: geometry.size.width * progress) + } + } + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(program.displayTitle) + .font(.callout) + .fontWeight(isFocused ? .semibold : .regular) + .lineLimit(2) + .foregroundColor(isFocused ? .black : .white) + + if let startDate = program.startDate, + let endDate = program.endDate + { + HStack(spacing: 4) { + Text(startDate, style: .time) + Text("-") + Text(endDate, style: .time) + } + .font(.caption2) + .foregroundColor(isFocused ? .black.opacity(0.7) : .secondary) + } + + if isCurrentlyAiring { + Text("Live") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(isFocused ? .black : .red) + } + } + .padding(8) + } + } + .frame(width: cellWidth, height: 100) + .buttonStyle(.card) + .onReceive(timer) { _ in + currentTime = Date.now + } + } + + private var backgroundColor: Color { + if isFocused { + return .white + } else if isCurrentlyAiring { + return Color.accentColor.opacity(0.2) + } else { + return Color(white: 0.2) + } + } + + private var borderColor: Color { + if isFocused { + return .white + } else if isCurrentlyAiring { + return .accentColor + } else { + return Color(white: 0.3) + } + } + + private func handleSelection() { + // For Live TV from EPG, we play the channel + // If program is currently airing, playback will start from current position + // If program is in the future, channel will start playing whatever is currently on + guard let mediaSource = channel.mediaSources?.first else { return } + + router.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel, mediaSource: mediaSource) + ) + } +} diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift b/jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift new file mode 100644 index 00000000..d1368489 --- /dev/null +++ b/jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift @@ -0,0 +1,66 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct EPGTimelineHeader: View { + + let timeWindowStart: Date + let timeWindowEnd: Date + let pixelsPerMinute: CGFloat + + private let channelColumnWidth: CGFloat = 200 + private let timeSlotInterval: TimeInterval = 1800 // 30 minutes + + var body: some View { + HStack(spacing: 0) { + // Empty space for channel column + Color.clear + .frame(width: channelColumnWidth) + + // Time markers + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(timeSlots, id: \.self) { time in + timeMarker(for: time) + } + } + .padding(.horizontal, 8) + } + } + .frame(height: 50) + .background(Color(white: 0.15)) + } + + private var timeSlots: [Date] { + var slots: [Date] = [] + var currentTime = timeWindowStart + + while currentTime <= timeWindowEnd { + slots.append(currentTime) + currentTime = currentTime.addingTimeInterval(timeSlotInterval) + } + + return slots + } + + private func timeMarker(for date: Date) -> some View { + let slotWidth = CGFloat(timeSlotInterval / 60) * pixelsPerMinute + + return VStack(spacing: 4) { + Text(date, style: .time) + .font(.headline) + .fontWeight(.semibold) + + Rectangle() + .fill(Color.secondary) + .frame(width: 2, height: 15) + } + .frame(width: slotWidth) + } +} diff --git a/jellypig tvOS/Views/ProgramsView/ProgramsView.swift b/jellypig tvOS/Views/ProgramsView/ProgramsView.swift index 7eabf0b6..1455dc8b 100644 --- a/jellypig tvOS/Views/ProgramsView/ProgramsView.swift +++ b/jellypig tvOS/Views/ProgramsView/ProgramsView.swift @@ -79,6 +79,9 @@ struct ProgramsView: View { var body: some View { ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() + switch programsViewModel.state { case .content: if programsViewModel.hasNoResults { diff --git a/jellypig tvOS/Views/SearchView.swift b/jellypig tvOS/Views/SearchView.swift index 2ba04fa9..8bfc3e8e 100644 --- a/jellypig tvOS/Views/SearchView.swift +++ b/jellypig tvOS/Views/SearchView.swift @@ -111,6 +111,9 @@ struct SearchView: View { var body: some View { ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() + switch viewModel.state { case .initial: suggestionsView diff --git a/jellypig.xcodeproj/project.pbxproj b/jellypig.xcodeproj/project.pbxproj index fb29a6a1..2d88c34f 100644 --- a/jellypig.xcodeproj/project.pbxproj +++ b/jellypig.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 21951AC22D9D2010002E03E0 /* AddUserBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */; }; 21BCDEF72D9C822000E1D180 /* AddUserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */; }; + 43D8DAACB1A6D59470D31082 /* EPGViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ED7169D761EBCCC34650B1 /* EPGViewModel.swift */; }; + 4C0A02DD28ED5E02DDE52088 /* EPGProgramCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */; }; 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; @@ -94,6 +96,7 @@ 4EF36F652D962A430065BB79 /* ItemSortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF36F632D962A430065BB79 /* ItemSortBy.swift */; }; 4EF36F672D9649050065BB79 /* SessionInfoDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDBDCD02CBDD6510033D347 /* SessionInfoDto.swift */; }; 4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; }; + 527E650F94265266C416CFD8 /* ProgramGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C1FE779A04986789C1D943 /* ProgramGuideView.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; @@ -101,6 +104,7 @@ 535870632669D21600D05A09 /* jellypigapp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* jellypigapp.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; + 536803180875374165091699 /* EPGTimelineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF326D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCC26D323FE00EB3286 /* Localizable.strings */; }; 53913BF626D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCF26D323FE00EB3286 /* Localizable.strings */; }; @@ -178,6 +182,7 @@ 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 9F6FDB6C675373491EB57B41 /* SeasonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2919FFF7C404A6AD31658B2 /* SeasonHStack.swift */; }; B553DE52A96FE4289D1E6996 /* AttributeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65CB977628965AA9099742F /* AttributeBadge.swift */; }; + B56E653D9B9A40ED072ED318 /* EPGCurrentTimeIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */; }; BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */; }; BD88CB422D77E6A0006BB5E3 /* TVOSPicker in Frameworks */ = {isa = PBXBuildFile; productRef = BD88CB412D77E6A0006BB5E3 /* TVOSPicker */; }; @@ -196,6 +201,7 @@ C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; }; C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */; }; C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* MediaView.swift */; }; + C606AA8295DF9645C57237D6 /* EPGChannelRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BFAA71C5194626E96E5F838 /* EPGChannelRow.swift */; }; CC787DD1C212FF9BB2542D28 /* VideoRangeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C80CEDC871A21D98141BBE /* VideoRangeType.swift */; }; DFB7C3E02C7AA43A00CE7CDC /* UserSignInState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */; }; E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; @@ -551,9 +557,11 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + 0BFAA71C5194626E96E5F838 /* EPGChannelRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGChannelRow.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift"; sourceTree = ""; }; 12C80CEDC871A21D98141BBE /* VideoRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRangeType.swift; sourceTree = ""; }; 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserBottomButton.swift; sourceTree = ""; }; 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserGridButton.swift; sourceTree = ""; }; + 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGTimelineHeader.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift"; sourceTree = ""; }; 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; @@ -917,6 +925,8 @@ 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectAuthorizeView.swift; sourceTree = ""; }; 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectAuthorizeViewModel.swift; sourceTree = ""; }; 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UDPBroadcast.xcframework; path = Carthage/Build/UDPBroadcast.xcframework; sourceTree = ""; }; + 67ED7169D761EBCCC34650B1 /* EPGViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGViewModel.swift; path = ../Shared/ViewModels/EPGViewModel.swift; sourceTree = ""; }; + B2C1FE779A04986789C1D943 /* ProgramGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProgramGuideView.swift; path = "../jellypig tvOS/Views/ProgramGuideView.swift"; sourceTree = ""; }; B65CB977628965AA9099742F /* AttributeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeBadge.swift; sourceTree = ""; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = ""; }; BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; sourceTree = ""; }; @@ -948,6 +958,7 @@ C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = ""; }; C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = ""; }; C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; + C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGProgramCell.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift"; sourceTree = ""; }; DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInState.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailViewModel.swift; sourceTree = ""; }; @@ -1408,6 +1419,7 @@ E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScenePhaseChangedModifier.swift; sourceTree = ""; }; + E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGCurrentTimeIndicator.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2725,6 +2737,8 @@ E1DD1127271E7D15005BE12F /* Objects */, E1DCDE3B2A2D134000FA9C91 /* Resources */, E13DD3D027165886009D4DAF /* Views */, + A9B355418BEC3896FA6490EA /* Shared */, + 7921755ED42A950421AEEF8E /* jellypig tvOS */, ); path = jellypig; sourceTree = ""; @@ -3172,6 +3186,17 @@ path = "zh-Hant.lproj"; sourceTree = ""; }; + 6211D44F6675191660F709BC /* Components */ = { + isa = PBXGroup; + children = ( + C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */, + 0BFAA71C5194626E96E5F838 /* EPGChannelRow.swift */, + 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */, + E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */, + ); + name = Components; + sourceTree = ""; + }; 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( @@ -3278,6 +3303,39 @@ path = HStacks; sourceTree = ""; }; + 6C70C653BDDF18CB02C02D82 /* Views */ = { + isa = PBXGroup; + children = ( + 7D7484DE244C0DAFD185432A /* ProgramGuideView */, + ); + name = Views; + sourceTree = ""; + }; + 7921755ED42A950421AEEF8E /* jellypig tvOS */ = { + isa = PBXGroup; + children = ( + 6C70C653BDDF18CB02C02D82 /* Views */, + ); + name = "jellypig tvOS"; + sourceTree = ""; + }; + 7D7484DE244C0DAFD185432A /* ProgramGuideView */ = { + isa = PBXGroup; + children = ( + 6211D44F6675191660F709BC /* Components */, + B2C1FE779A04986789C1D943 /* ProgramGuideView.swift */, + ); + name = ProgramGuideView; + sourceTree = ""; + }; + A9B355418BEC3896FA6490EA /* Shared */ = { + isa = PBXGroup; + children = ( + F6DB96C6C0E59551858F8D9F /* ViewModels */, + ); + name = Shared; + sourceTree = ""; + }; BD0BA2292AD6501300306A8D /* VideoPlayerManager */ = { isa = PBXGroup; children = ( @@ -4628,6 +4686,14 @@ path = NavigationBarFilterDrawer; sourceTree = ""; }; + F6DB96C6C0E59551858F8D9F /* ViewModels */ = { + isa = PBXGroup; + children = ( + 67ED7169D761EBCCC34650B1 /* EPGViewModel.swift */, + ); + name = ViewModels; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -5346,6 +5412,12 @@ B553DE52A96FE4289D1E6996 /* AttributeBadge.swift in Sources */, CC787DD1C212FF9BB2542D28 /* VideoRangeType.swift in Sources */, 9F6FDB6C675373491EB57B41 /* SeasonHStack.swift in Sources */, + 43D8DAACB1A6D59470D31082 /* EPGViewModel.swift in Sources */, + 4C0A02DD28ED5E02DDE52088 /* EPGProgramCell.swift in Sources */, + C606AA8295DF9645C57237D6 /* EPGChannelRow.swift in Sources */, + 536803180875374165091699 /* EPGTimelineHeader.swift in Sources */, + B56E653D9B9A40ED072ED318 /* EPGCurrentTimeIndicator.swift in Sources */, + 527E650F94265266C416CFD8 /* ProgramGuideView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };