[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
This commit is contained in:
Joe Kribs 2025-03-12 07:37:18 -06:00 committed by GitHub
parent 718ea0f187
commit 8f21860e5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 162 additions and 88 deletions

View File

@ -39,6 +39,7 @@ extension SeriesEpisodeSelector {
private func contentView(viewModel: SeasonItemViewModel) -> some View { private func contentView(viewModel: SeasonItemViewModel) -> some View {
CollectionHStack( CollectionHStack(
uniqueElements: viewModel.elements, uniqueElements: viewModel.elements,
id: \.unwrappedIDHashOrZero,
columns: 3.5 columns: 3.5
) { episode in ) { episode in
SeriesEpisodeSelector.EpisodeCard(episode: episode) 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 // MARK: - Body
var body: some View { var body: some View {
@ -85,18 +114,7 @@ extension SeriesEpisodeSelector {
focusGuide, focusGuide,
tag: "episodes", tag: "episodes",
onContentFocus: { onContentFocus: {
switch viewModel.state { getContentFocus()
case .content:
if viewModel.elements.isEmpty {
focusedEpisodeID = "EmptyCard"
} else {
focusedEpisodeID = lastFocusedEpisodeID
}
case .error:
focusedEpisodeID = "ErrorCard"
case .initial, .refreshing:
focusedEpisodeID = "LoadingCard"
}
}, },
top: "seasons" top: "seasons"
) )
@ -127,7 +145,7 @@ extension SeriesEpisodeSelector {
columns: 3.5 columns: 3.5
) { _ in ) { _ in
SeriesEpisodeSelector.EmptyCard() SeriesEpisodeSelector.EmptyCard()
.focused(focusedEpisodeID, equals: "EmptyCard") .focused(focusedEpisodeID, equals: "emptyCard")
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.allowScrolling(false) .allowScrolling(false)
@ -155,7 +173,7 @@ extension SeriesEpisodeSelector {
.onSelect { .onSelect {
viewModel.send(.refresh) viewModel.send(.refresh)
} }
.focused(focusedEpisodeID, equals: "ErrorCard") .focused(focusedEpisodeID, equals: "errorCard")
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.allowScrolling(false) .allowScrolling(false)
@ -176,7 +194,7 @@ extension SeriesEpisodeSelector {
columns: 3.5 columns: 3.5
) { _ in ) { _ in
SeriesEpisodeSelector.LoadingCard() SeriesEpisodeSelector.LoadingCard()
.focused(focusedEpisodeID, equals: "LoadingCard") .focused(focusedEpisodeID, equals: "loadingCard")
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.allowScrolling(false) .allowScrolling(false)

View File

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

View File

@ -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<SeasonItemViewModel.ID?>
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
}
}
}
}

View File

@ -231,6 +231,7 @@
4EDDB49C2D596E1200DA16E8 /* VersionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */; }; 4EDDB49C2D596E1200DA16E8 /* VersionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */; };
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; };
4EE07CBC2D08B19700B0B636 /* 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 */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; };
4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; }; 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; };
4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.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 = "<group>"; }; 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = "<group>"; }; 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = "<group>"; };
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; }; 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; };
4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonHStack.swift; sourceTree = "<group>"; };
4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = "<group>"; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = "<group>"; };
4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemView.swift; sourceTree = "<group>"; }; 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemView.swift; sourceTree = "<group>"; };
4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemViewModel.swift; sourceTree = "<group>"; }; 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemViewModel.swift; sourceTree = "<group>"; };
@ -2980,6 +2982,15 @@
path = EditAccessScheduleView; path = EditAccessScheduleView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4EE0DCFE2D78D74E00AAD0D3 /* HStacks */ = {
isa = PBXGroup;
children = (
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */,
4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */,
);
path = HStacks;
sourceTree = "<group>";
};
4EE766F32D131F6E009658F0 /* IdentifyItemView */ = { 4EE766F32D131F6E009658F0 /* IdentifyItemView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -4294,8 +4305,8 @@
4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */, 4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */,
E1C926092887565C002A7A66 /* EpisodeCard.swift */, E1C926092887565C002A7A66 /* EpisodeCard.swift */,
E1153D932BBA3D3000424D36 /* EpisodeContent.swift */, E1153D932BBA3D3000424D36 /* EpisodeContent.swift */,
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */,
E1153D992BBA3E9800424D36 /* ErrorCard.swift */, E1153D992BBA3E9800424D36 /* ErrorCard.swift */,
4EE0DCFE2D78D74E00AAD0D3 /* HStacks */,
E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */, E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */,
); );
path = Components; path = Components;
@ -6262,6 +6273,7 @@
E1D4BF8F271A079A00A11E64 /* AppSettingsView.swift in Sources */, E1D4BF8F271A079A00A11E64 /* AppSettingsView.swift in Sources */,
E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */,
E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */, E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */,
4EE0DCFD2D78D2B700AAD0D3 /* SeasonHStack.swift in Sources */,
E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */, E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */,
4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */, 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */,
4EBE064E2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */, 4EBE064E2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */,

View File

@ -31,6 +31,7 @@ extension SeriesEpisodeSelector {
private func contentView(viewModel: SeasonItemViewModel) -> some View { private func contentView(viewModel: SeasonItemViewModel) -> some View {
CollectionHStack( CollectionHStack(
uniqueElements: viewModel.elements, uniqueElements: viewModel.elements,
id: \.unwrappedIDHashOrZero,
columns: UIDevice.isPhone ? 1.5 : 3.5 columns: UIDevice.isPhone ? 1.5 : 3.5
) { episode in ) { episode in
SeriesEpisodeSelector.EpisodeCard(episode: episode) SeriesEpisodeSelector.EpisodeCard(episode: episode)