[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:
parent
718ea0f187
commit
8f21860e5e
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -2980,6 +2982,15 @@
|
|||
path = EditAccessScheduleView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EE0DCFE2D78D74E00AAD0D3 /* HStacks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */,
|
||||
4EE0DCFC2D78D2B400AAD0D3 /* SeasonHStack.swift */,
|
||||
);
|
||||
path = HStacks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue