From 8f21860e5e5e2f346f4c6526cc22f41bcc2ddbc4 Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Wed, 12 Mar 2025 07:37:18 -0600 Subject: [PATCH] [tvOS] Season Selector Scrolling Bug (#1446) * Ensure selectionViewModel is a valid season. * Scolling issue * Fix Scrolling! But fails to set button color... * Move HStacks into their own folder. * CollectionHStack * SeasonHStack FINALLY done! * ScrollToIndex NOT Element * Remove refocus work * Undo extra changes not for this PR * Fix Episode Scrolling on iOS as well. * Add the `.mask` in... Whatever that actually does??? Just trying to mirror the older version * Linting * Use playButtonItem instead * Season ScrollTo * Cleanup. * Even more cleanup. * Even more cleanup and comments * LInting --- .../{ => HStacks}/EpisodeHStack.swift | 48 +++++--- .../Components/HStacks/SeasonHStack.swift | 115 ++++++++++++++++++ .../EpisodeSelector/EpisodeSelector.swift | 72 ----------- Swiftfin.xcodeproj/project.pbxproj | 14 ++- .../Components/EpisodeHStack.swift | 1 + 5 files changed, 162 insertions(+), 88 deletions(-) rename Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/{ => HStacks}/EpisodeHStack.swift (77%) create mode 100644 Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift similarity index 77% rename from Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift index 4191c2be..3f7ac053 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift @@ -39,6 +39,7 @@ extension SeriesEpisodeSelector { private func contentView(viewModel: SeasonItemViewModel) -> some View { CollectionHStack( uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, columns: 3.5 ) { episode in SeriesEpisodeSelector.EpisodeCard(episode: episode) @@ -62,6 +63,34 @@ extension SeriesEpisodeSelector { } } + // MARK: - Determine Which Episode should be Focused + + private func getContentFocus() { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + /// Focus the EmptyCard if the Season has no elements + focusedEpisodeID = "emptyCard" + } else { + if let lastFocusedEpisodeID, + viewModel.elements.contains(where: { $0.id == lastFocusedEpisodeID }) + { + /// Return focus to the Last Focused Episode if it exists in the current Season + focusedEpisodeID = lastFocusedEpisodeID + } else { + /// Focus the First Episode in the season as a last resort + focusedEpisodeID = viewModel.elements.first?.id + } + } + case .error: + /// Focus the ErrorCard if the Season failed to load + focusedEpisodeID = "errorCard" + case .initial, .refreshing: + /// Focus the LoadingCard if the Season is currently loading + focusedEpisodeID = "loadingCard" + } + } + // MARK: - Body var body: some View { @@ -85,18 +114,7 @@ extension SeriesEpisodeSelector { focusGuide, tag: "episodes", onContentFocus: { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - focusedEpisodeID = "EmptyCard" - } else { - focusedEpisodeID = lastFocusedEpisodeID - } - case .error: - focusedEpisodeID = "ErrorCard" - case .initial, .refreshing: - focusedEpisodeID = "LoadingCard" - } + getContentFocus() }, top: "seasons" ) @@ -127,7 +145,7 @@ extension SeriesEpisodeSelector { columns: 3.5 ) { _ in SeriesEpisodeSelector.EmptyCard() - .focused(focusedEpisodeID, equals: "EmptyCard") + .focused(focusedEpisodeID, equals: "emptyCard") .padding(.horizontal, 4) } .allowScrolling(false) @@ -155,7 +173,7 @@ extension SeriesEpisodeSelector { .onSelect { viewModel.send(.refresh) } - .focused(focusedEpisodeID, equals: "ErrorCard") + .focused(focusedEpisodeID, equals: "errorCard") .padding(.horizontal, 4) } .allowScrolling(false) @@ -176,7 +194,7 @@ extension SeriesEpisodeSelector { columns: 3.5 ) { _ in SeriesEpisodeSelector.LoadingCard() - .focused(focusedEpisodeID, equals: "LoadingCard") + .focused(focusedEpisodeID, equals: "loadingCard") .padding(.horizontal, 4) } .allowScrolling(false) diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift new file mode 100644 index 00000000..13bd1be0 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift @@ -0,0 +1,115 @@ +// +// 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 SeasonsHStack: View { + + // MARK: - Environment & Observed Objects + + @EnvironmentObject + private var focusGuide: FocusGuide + + @ObservedObject + var viewModel: SeriesItemViewModel + + // MARK: - Selection Binding + + @Binding + var selection: SeasonItemViewModel.ID? + + // MARK: - Focus Variables + + @FocusState + private var focusedSeason: SeasonItemViewModel.ID? + + @State + private var didScrollToPlayButtonSeason = false + + // MARK: - Body + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: EdgeInsets.edgePadding / 2) { + ForEach(viewModel.seasons) { season in + seasonButton(season: season) + .id(season.id) + } + } + .padding(.horizontal, EdgeInsets.edgePadding) + } + .padding(.bottom, 45) + .focusSection() + .focusGuide( + focusGuide, + tag: "seasons", + onContentFocus: { focusedSeason = selection }, + top: "top", + bottom: "episodes" + ) + .mask { + VStack(spacing: 0) { + Color.white + + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 20) + } + } + .onChange(of: focusedSeason) { _, newValue in + if let newValue = newValue { + selection = newValue + } + } + .onFirstAppear { + guard !didScrollToPlayButtonSeason else { return } + didScrollToPlayButtonSeason = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let selection else { return } + + proxy.scrollTo(selection) + } + } + } + } + + // MARK: - Season Button + + @ViewBuilder + private func seasonButton(season: SeasonItemViewModel) -> some View { + Button { + selection = season.id + } label: { + Text(season.season.displayTitle) + .font(.headline) + .fontWeight(.semibold) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .if(selection == season.id) { text in + text + .background(.white) + .foregroundColor(.black) + } + } + .focused($focusedSeason, equals: season.id) + .buttonStyle(.card) + .padding(.horizontal, 4) + .padding(.vertical) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift index 5f915845..cb527d34 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -65,75 +65,3 @@ struct SeriesEpisodeSelector: View { } } } - -extension SeriesEpisodeSelector { - - // MARK: SeasonsHStack - - struct SeasonsHStack: View { - - @EnvironmentObject - private var focusGuide: FocusGuide - - @FocusState - private var focusedSeason: SeasonItemViewModel.ID? - - @ObservedObject - var viewModel: SeriesItemViewModel - - var selection: Binding - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(viewModel.seasons) { seasonViewModel in - Button { - Text(seasonViewModel.season.displayTitle) - .font(.headline) - .fontWeight(.semibold) - .padding(.vertical, 10) - .padding(.horizontal, 20) - .if(selection.wrappedValue == seasonViewModel.id) { text in - text - .background(Color.white) - .foregroundColor(.black) - } - } - .buttonStyle(.card) - .focused($focusedSeason, equals: seasonViewModel.id) - } - } - .focusGuide( - focusGuide, - tag: "seasons", - onContentFocus: { focusedSeason = selection.wrappedValue }, - top: "top", - bottom: "episodes" - ) - .frame(height: 70) - .padding(.horizontal, 50) - .padding(.top) - .padding(.bottom, 45) - } - .mask { - VStack(spacing: 0) { - Color.white - - LinearGradient( - stops: [ - .init(color: .white, location: 0), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 20) - } - } - .onChange(of: focusedSeason) { _, newValue in - guard let newValue else { return } - selection.wrappedValue = newValue - } - } - } -} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0f8224df..a89bd115 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ 4EDDB49C2D596E1200DA16E8 /* VersionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */; }; 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; + 4EE0DCFD2D78D2B700AAD0D3 /* SeasonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; }; 4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; @@ -1450,6 +1451,7 @@ 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = ""; }; 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; + 4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonHStack.swift; sourceTree = ""; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemView.swift; sourceTree = ""; }; 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemViewModel.swift; sourceTree = ""; }; @@ -2980,6 +2982,15 @@ path = EditAccessScheduleView; sourceTree = ""; }; + 4EE0DCFE2D78D74E00AAD0D3 /* HStacks */ = { + isa = PBXGroup; + children = ( + E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */, + 4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */, + ); + path = HStacks; + sourceTree = ""; + }; 4EE766F32D131F6E009658F0 /* IdentifyItemView */ = { isa = PBXGroup; children = ( @@ -4294,8 +4305,8 @@ 4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */, E1C926092887565C002A7A66 /* EpisodeCard.swift */, E1153D932BBA3D3000424D36 /* EpisodeContent.swift */, - E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */, E1153D992BBA3E9800424D36 /* ErrorCard.swift */, + 4EE0DCFE2D78D74E00AAD0D3 /* HStacks */, E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */, ); path = Components; @@ -6262,6 +6273,7 @@ E1D4BF8F271A079A00A11E64 /* AppSettingsView.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */, + 4EE0DCFD2D78D2B700AAD0D3 /* SeasonHStack.swift in Sources */, E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */, 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */, 4EBE064E2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */, diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift index bdd29c5a..f56b86c6 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -31,6 +31,7 @@ extension SeriesEpisodeSelector { private func contentView(viewModel: SeasonItemViewModel) -> some View { CollectionHStack( uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, columns: UIDevice.isPhone ? 1.5 : 3.5 ) { episode in SeriesEpisodeSelector.EpisodeCard(episode: episode)