From 718ea0f187e3418e0d20983d8171cba26c427807 Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Mon, 3 Mar 2025 21:29:51 -0700 Subject: [PATCH] [tvOS] Episode Selector - State & Focus Handling (#1435) * Catch empty episodes * Linting. * Mirror iOS more. Remove unused imports. Turn non-used cards into buttons to allow focus. * Allow focusing on Empty / Error cards. * Make ErrorCard Selectable * cleanup * Focusable Loading Card. * Fall back to empty season. * Last of the MacOS Catalyst stuff * Force Unwrap. * Don't force unwrap. * Remove unneeded `focusedSection` from `EpisodeSelector`. --------- Co-authored-by: Ethan Pippin --- .../Components/EmptyCard.swift | 49 ++++++++++++++ .../Components/EpisodeCard.swift | 25 ++++--- .../Components/EpisodeContent.swift | 4 ++ .../Components/EpisodeHStack.swift | 67 +++++++++++++++++-- .../Components/ErrorCard.swift | 24 ++++--- .../Components/LoadingCard.swift | 27 ++++++-- .../EpisodeSelector/EpisodeSelector.swift | 10 ++- Swiftfin.xcodeproj/project.pbxproj | 4 ++ 8 files changed, 177 insertions(+), 33 deletions(-) create mode 100644 Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift new file mode 100644 index 00000000..2d566331 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift @@ -0,0 +1,49 @@ +// +// 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 + +extension SeriesEpisodeSelector { + + struct EmptyCard: View { + + private var onSelect: () -> Void + + init() { + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + VStack(alignment: .leading) { + Button { + onSelect() + } label: { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "questionmark") + .font(.system(size: 40)) + } + } + .buttonStyle(.card) + .posterShadow() + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.noResults, + content: L10n.noEpisodesAvailable + ) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift index 7ad6e88c..12d5dc7f 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -6,13 +6,13 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import Defaults -import Factory import JellyfinAPI import SwiftUI extension SeriesEpisodeSelector { + struct EpisodeCard: View { + @EnvironmentObject private var router: ItemCoordinator.Router @@ -22,15 +22,24 @@ extension SeriesEpisodeSelector { private var isFocused: Bool @ViewBuilder - private var imageOverlay: some View { + private var overlayView: some View { ZStack { - if episode.userData?.isPlayed ?? false { - WatchedIndicator(size: 45) - } else if (episode.userData?.playbackPositionTicks ?? 0) > 0 { + if let progressLabel = episode.progressLabel { LandscapePosterProgressBar( - title: episode.progressLabel ?? L10n.continue, + title: progressLabel, progress: (episode.userData?.playedPercentage ?? 0) / 100 ) + } else if episode.userData?.isPlayed ?? false { + ZStack(alignment: .bottomTrailing) { + Color.clear + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 30, height: 30, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black) + .padding() + } } if isFocused { @@ -64,7 +73,7 @@ extension SeriesEpisodeSelector { SystemImageContentView(systemName: episode.systemImage) } - imageOverlay + overlayView } .posterStyle(.landscape) } diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift index 4352bcc8..cde1f4ab 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift @@ -11,7 +11,9 @@ import JellyfinAPI import SwiftUI extension SeriesEpisodeSelector { + struct EpisodeContent: View { + @Default(.accentColor) private var accentColor @@ -26,6 +28,7 @@ extension SeriesEpisodeSelector { Text(subHeader) .font(.caption) .foregroundColor(.secondary) + .lineLimit(1) } @ViewBuilder @@ -46,6 +49,7 @@ extension SeriesEpisodeSelector { .multilineTextAlignment(.leading) .backport .lineLimit(3, reservesSpace: true) + .font(.caption.weight(.light)) } var body: some View { diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift index d5302d84..4191c2be 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -34,6 +34,8 @@ extension SeriesEpisodeSelector { let playButtonItem: BaseItemDto? + // MARK: - Content View + private func contentView(viewModel: SeasonItemViewModel) -> some View { CollectionHStack( uniqueElements: viewModel.elements, @@ -53,7 +55,6 @@ extension SeriesEpisodeSelector { lastFocusedEpisodeID = playButtonItem?.id - // good enough? DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { guard let playButtonItem else { return } proxy.scrollTo(element: playButtonItem, animated: false) @@ -61,22 +62,42 @@ extension SeriesEpisodeSelector { } } + // MARK: - Body + var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: - contentView(viewModel: viewModel) + if viewModel.elements.isEmpty { + EmptyHStack(focusedEpisodeID: $focusedEpisodeID) + } else { + contentView(viewModel: viewModel) + } case let .error(error): - ErrorHStack(viewModel: viewModel, error: error) + ErrorHStack(viewModel: viewModel, error: error, focusedEpisodeID: $focusedEpisodeID) case .initial, .refreshing: - LoadingHStack() + LoadingHStack(focusedEpisodeID: $focusedEpisodeID) } } + .padding(.bottom, 45) .focusSection() .focusGuide( focusGuide, tag: "episodes", - onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID }, + onContentFocus: { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + focusedEpisodeID = "EmptyCard" + } else { + focusedEpisodeID = lastFocusedEpisodeID + } + case .error: + focusedEpisodeID = "ErrorCard" + case .initial, .refreshing: + focusedEpisodeID = "LoadingCard" + } + }, top: "seasons" ) .onChange(of: viewModel.id) { @@ -94,12 +115,36 @@ extension SeriesEpisodeSelector { } } + // MARK: - Empty HStack + + struct EmptyHStack: View { + + let focusedEpisodeID: FocusState.Binding + + var body: some View { + CollectionHStack( + count: 1, + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.EmptyCard() + .focused(focusedEpisodeID, equals: "EmptyCard") + .padding(.horizontal, 4) + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + } + } + + // MARK: - Error HStack + struct ErrorHStack: View { @ObservedObject var viewModel: SeasonItemViewModel let error: JellyfinAPIError + let focusedEpisodeID: FocusState.Binding var body: some View { CollectionHStack( @@ -110,6 +155,8 @@ extension SeriesEpisodeSelector { .onSelect { viewModel.send(.refresh) } + .focused(focusedEpisodeID, equals: "ErrorCard") + .padding(.horizontal, 4) } .allowScrolling(false) .insets(horizontal: EdgeInsets.edgePadding) @@ -117,14 +164,20 @@ extension SeriesEpisodeSelector { } } + // MARK: - Loading HStack + struct LoadingHStack: View { + let focusedEpisodeID: FocusState.Binding + var body: some View { CollectionHStack( - count: Int.random(in: 2 ..< 5), + count: 1, columns: 3.5 ) { _ in SeriesEpisodeSelector.LoadingCard() + .focused(focusedEpisodeID, equals: "LoadingCard") + .padding(.horizontal, 4) } .allowScrolling(false) .insets(horizontal: EdgeInsets.edgePadding) diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift index 30b57b91..8ec8437d 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift @@ -25,24 +25,26 @@ extension SeriesEpisodeSelector { } var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading) { + VStack(alignment: .leading) { + Button { + onSelect() + } label: { Color.secondarySystemFill .opacity(0.75) .posterStyle(.landscape) .overlay { - Image(systemName: "arrow.clockwise.circle.fill") + Image(systemName: "arrow.clockwise") .font(.system(size: 40)) } - - SeriesEpisodeSelector.EpisodeContent( - subHeader: .emptyDash, - header: L10n.error, - content: error.localizedDescription - ) } + .buttonStyle(.card) + .posterShadow() + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.error, + content: error.localizedDescription + ) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift index 6a907269..f28bb097 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift @@ -6,19 +6,36 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import Foundation -import JellyfinAPI import SwiftUI extension SeriesEpisodeSelector { struct LoadingCard: View { + private var onSelect: () -> Void + + init() { + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + var body: some View { VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) + Button { + onSelect() + } label: { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + ProgressView() + } + } + .buttonStyle(.card) + .posterShadow() SeriesEpisodeSelector.EpisodeContent( subHeader: String.random(count: 7 ..< 12), diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift index f027096b..5f915845 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -12,21 +12,29 @@ import SwiftUI struct SeriesEpisodeSelector: View { + // MARK: - Observed & Environment Objects + @ObservedObject var viewModel: SeriesItemViewModel @EnvironmentObject private var parentFocusGuide: FocusGuide + // MARK: - State Variables + @State private var didSelectPlayButtonSeason = false @State private var selection: SeasonItemViewModel.ID? + // MARK: - Calculated Variables + private var selectionViewModel: SeasonItemViewModel? { viewModel.seasons.first(where: { $0.id == selection }) } + // MARK: - Body + var body: some View { VStack(spacing: 0) { SeasonsHStack(viewModel: viewModel, selection: $selection) @@ -35,8 +43,6 @@ struct SeriesEpisodeSelector: View { if let selectionViewModel { EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) .environmentObject(parentFocusGuide) - } else { - LoadingHStack() } } .onReceive(viewModel.playButtonItem.publisher) { newValue in diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 9ffbca6f..0f8224df 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; + 4E79F27C2D6BAAC500FE1A52 /* EmptyCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */; }; 4E8274F52D2ECF1900F5E610 /* UserProfileSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */; }; 4E884C652CEBB301004CF6AD /* LearnMoreModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; @@ -1380,6 +1381,7 @@ 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; + 4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyCard.swift; sourceTree = ""; }; 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSettingsCoordinator.swift; sourceTree = ""; }; 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; @@ -4289,6 +4291,7 @@ E1153D972BBA3E5300424D36 /* Components */ = { isa = PBXGroup; children = ( + 4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */, E1C926092887565C002A7A66 /* EpisodeCard.swift */, E1153D932BBA3D3000424D36 /* EpisodeContent.swift */, E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */, @@ -5907,6 +5910,7 @@ 4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */, 4E97D1832D064748004B89AD /* ItemSection.swift in Sources */, E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */, + 4E79F27C2D6BAAC500FE1A52 /* EmptyCard.swift in Sources */, E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */,