[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 {
|
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)
|
|
@ -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 */; };
|
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 */,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue