[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 <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-03-03 21:29:51 -07:00 committed by GitHub
parent d2c5ac9985
commit 718ea0f187
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 177 additions and 33 deletions

View File

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

View File

@ -6,13 +6,13 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import Defaults
import Factory
import JellyfinAPI import JellyfinAPI
import SwiftUI import SwiftUI
extension SeriesEpisodeSelector { extension SeriesEpisodeSelector {
struct EpisodeCard: View { struct EpisodeCard: View {
@EnvironmentObject @EnvironmentObject
private var router: ItemCoordinator.Router private var router: ItemCoordinator.Router
@ -22,15 +22,24 @@ extension SeriesEpisodeSelector {
private var isFocused: Bool private var isFocused: Bool
@ViewBuilder @ViewBuilder
private var imageOverlay: some View { private var overlayView: some View {
ZStack { ZStack {
if episode.userData?.isPlayed ?? false { if let progressLabel = episode.progressLabel {
WatchedIndicator(size: 45)
} else if (episode.userData?.playbackPositionTicks ?? 0) > 0 {
LandscapePosterProgressBar( LandscapePosterProgressBar(
title: episode.progressLabel ?? L10n.continue, title: progressLabel,
progress: (episode.userData?.playedPercentage ?? 0) / 100 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 { if isFocused {
@ -64,7 +73,7 @@ extension SeriesEpisodeSelector {
SystemImageContentView(systemName: episode.systemImage) SystemImageContentView(systemName: episode.systemImage)
} }
imageOverlay overlayView
} }
.posterStyle(.landscape) .posterStyle(.landscape)
} }

View File

@ -11,7 +11,9 @@ import JellyfinAPI
import SwiftUI import SwiftUI
extension SeriesEpisodeSelector { extension SeriesEpisodeSelector {
struct EpisodeContent: View { struct EpisodeContent: View {
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
@ -26,6 +28,7 @@ extension SeriesEpisodeSelector {
Text(subHeader) Text(subHeader)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1)
} }
@ViewBuilder @ViewBuilder
@ -46,6 +49,7 @@ extension SeriesEpisodeSelector {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.backport .backport
.lineLimit(3, reservesSpace: true) .lineLimit(3, reservesSpace: true)
.font(.caption.weight(.light))
} }
var body: some View { var body: some View {

View File

@ -34,6 +34,8 @@ extension SeriesEpisodeSelector {
let playButtonItem: BaseItemDto? let playButtonItem: BaseItemDto?
// MARK: - Content View
private func contentView(viewModel: SeasonItemViewModel) -> some View { private func contentView(viewModel: SeasonItemViewModel) -> some View {
CollectionHStack( CollectionHStack(
uniqueElements: viewModel.elements, uniqueElements: viewModel.elements,
@ -53,7 +55,6 @@ extension SeriesEpisodeSelector {
lastFocusedEpisodeID = playButtonItem?.id lastFocusedEpisodeID = playButtonItem?.id
// good enough?
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
guard let playButtonItem else { return } guard let playButtonItem else { return }
proxy.scrollTo(element: playButtonItem, animated: false) proxy.scrollTo(element: playButtonItem, animated: false)
@ -61,22 +62,42 @@ extension SeriesEpisodeSelector {
} }
} }
// MARK: - Body
var body: some View { var body: some View {
WrappedView { ZStack {
switch viewModel.state { switch viewModel.state {
case .content: case .content:
contentView(viewModel: viewModel) if viewModel.elements.isEmpty {
EmptyHStack(focusedEpisodeID: $focusedEpisodeID)
} else {
contentView(viewModel: viewModel)
}
case let .error(error): case let .error(error):
ErrorHStack(viewModel: viewModel, error: error) ErrorHStack(viewModel: viewModel, error: error, focusedEpisodeID: $focusedEpisodeID)
case .initial, .refreshing: case .initial, .refreshing:
LoadingHStack() LoadingHStack(focusedEpisodeID: $focusedEpisodeID)
} }
} }
.padding(.bottom, 45)
.focusSection() .focusSection()
.focusGuide( .focusGuide(
focusGuide, focusGuide,
tag: "episodes", 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" top: "seasons"
) )
.onChange(of: viewModel.id) { .onChange(of: viewModel.id) {
@ -94,12 +115,36 @@ extension SeriesEpisodeSelector {
} }
} }
// MARK: - Empty HStack
struct EmptyHStack: View {
let focusedEpisodeID: FocusState<String?>.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 { struct ErrorHStack: View {
@ObservedObject @ObservedObject
var viewModel: SeasonItemViewModel var viewModel: SeasonItemViewModel
let error: JellyfinAPIError let error: JellyfinAPIError
let focusedEpisodeID: FocusState<String?>.Binding
var body: some View { var body: some View {
CollectionHStack( CollectionHStack(
@ -110,6 +155,8 @@ extension SeriesEpisodeSelector {
.onSelect { .onSelect {
viewModel.send(.refresh) viewModel.send(.refresh)
} }
.focused(focusedEpisodeID, equals: "ErrorCard")
.padding(.horizontal, 4)
} }
.allowScrolling(false) .allowScrolling(false)
.insets(horizontal: EdgeInsets.edgePadding) .insets(horizontal: EdgeInsets.edgePadding)
@ -117,14 +164,20 @@ extension SeriesEpisodeSelector {
} }
} }
// MARK: - Loading HStack
struct LoadingHStack: View { struct LoadingHStack: View {
let focusedEpisodeID: FocusState<String?>.Binding
var body: some View { var body: some View {
CollectionHStack( CollectionHStack(
count: Int.random(in: 2 ..< 5), count: 1,
columns: 3.5 columns: 3.5
) { _ in ) { _ in
SeriesEpisodeSelector.LoadingCard() SeriesEpisodeSelector.LoadingCard()
.focused(focusedEpisodeID, equals: "LoadingCard")
.padding(.horizontal, 4)
} }
.allowScrolling(false) .allowScrolling(false)
.insets(horizontal: EdgeInsets.edgePadding) .insets(horizontal: EdgeInsets.edgePadding)

View File

@ -25,24 +25,26 @@ extension SeriesEpisodeSelector {
} }
var body: some View { var body: some View {
Button { VStack(alignment: .leading) {
onSelect() Button {
} label: { onSelect()
VStack(alignment: .leading) { } label: {
Color.secondarySystemFill Color.secondarySystemFill
.opacity(0.75) .opacity(0.75)
.posterStyle(.landscape) .posterStyle(.landscape)
.overlay { .overlay {
Image(systemName: "arrow.clockwise.circle.fill") Image(systemName: "arrow.clockwise")
.font(.system(size: 40)) .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
)
} }
} }
} }

View File

@ -6,19 +6,36 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import Foundation
import JellyfinAPI
import SwiftUI import SwiftUI
extension SeriesEpisodeSelector { extension SeriesEpisodeSelector {
struct LoadingCard: View { 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 { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Color.secondarySystemFill Button {
.opacity(0.75) onSelect()
.posterStyle(.landscape) } label: {
Color.secondarySystemFill
.opacity(0.75)
.posterStyle(.landscape)
.overlay {
ProgressView()
}
}
.buttonStyle(.card)
.posterShadow()
SeriesEpisodeSelector.EpisodeContent( SeriesEpisodeSelector.EpisodeContent(
subHeader: String.random(count: 7 ..< 12), subHeader: String.random(count: 7 ..< 12),

View File

@ -12,21 +12,29 @@ import SwiftUI
struct SeriesEpisodeSelector: View { struct SeriesEpisodeSelector: View {
// MARK: - Observed & Environment Objects
@ObservedObject @ObservedObject
var viewModel: SeriesItemViewModel var viewModel: SeriesItemViewModel
@EnvironmentObject @EnvironmentObject
private var parentFocusGuide: FocusGuide private var parentFocusGuide: FocusGuide
// MARK: - State Variables
@State @State
private var didSelectPlayButtonSeason = false private var didSelectPlayButtonSeason = false
@State @State
private var selection: SeasonItemViewModel.ID? private var selection: SeasonItemViewModel.ID?
// MARK: - Calculated Variables
private var selectionViewModel: SeasonItemViewModel? { private var selectionViewModel: SeasonItemViewModel? {
viewModel.seasons.first(where: { $0.id == selection }) viewModel.seasons.first(where: { $0.id == selection })
} }
// MARK: - Body
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
SeasonsHStack(viewModel: viewModel, selection: $selection) SeasonsHStack(viewModel: viewModel, selection: $selection)
@ -35,8 +43,6 @@ struct SeriesEpisodeSelector: View {
if let selectionViewModel { if let selectionViewModel {
EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem)
.environmentObject(parentFocusGuide) .environmentObject(parentFocusGuide)
} else {
LoadingHStack()
} }
} }
.onReceive(viewModel.playButtonItem.publisher) { newValue in .onReceive(viewModel.playButtonItem.publisher) { newValue in

View File

@ -151,6 +151,7 @@
4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; };
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; };
4E762AAF2C3A1A95004D1579 /* 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 */; }; 4E8274F52D2ECF1900F5E610 /* UserProfileSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */; };
4E884C652CEBB301004CF6AD /* LearnMoreModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */; }; 4E884C652CEBB301004CF6AD /* LearnMoreModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */; };
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.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 = "<group>"; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = "<group>"; }; 4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = "<group>"; };
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyCard.swift; sourceTree = "<group>"; };
4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSettingsCoordinator.swift; sourceTree = "<group>"; }; 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSettingsCoordinator.swift; sourceTree = "<group>"; };
4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = "<group>"; }; 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = "<group>"; };
4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; };
@ -4289,6 +4291,7 @@
E1153D972BBA3E5300424D36 /* Components */ = { E1153D972BBA3E5300424D36 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E79F27B2D6BAAC200FE1A52 /* EmptyCard.swift */,
E1C926092887565C002A7A66 /* EpisodeCard.swift */, E1C926092887565C002A7A66 /* EpisodeCard.swift */,
E1153D932BBA3D3000424D36 /* EpisodeContent.swift */, E1153D932BBA3D3000424D36 /* EpisodeContent.swift */,
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */, E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */,
@ -5907,6 +5910,7 @@
4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */, 4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */,
4E97D1832D064748004B89AD /* ItemSection.swift in Sources */, 4E97D1832D064748004B89AD /* ItemSection.swift in Sources */,
E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */, E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */,
4E79F27C2D6BAAC500FE1A52 /* EmptyCard.swift in Sources */,
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */,
E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */,