Skip to content

Commit

Permalink
Merge branch 'jellyfin:main' into addToPlaylists
Browse files Browse the repository at this point in the history
  • Loading branch information
JPKribs authored Mar 4, 2025
2 parents b51703a + 718ea0f commit 4f18463
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Defaults
import Factory
import JellyfinAPI
import SwiftUI

extension SeriesEpisodeSelector {

struct EpisodeCard: View {

@EnvironmentObject
private var router: ItemCoordinator.Router

Expand All @@ -22,15 +22,24 @@ extension SeriesEpisodeSelector {
private var isFocused: Bool

@ViewBuilder
private var imageOverlay: some View {
private var overlayView: some View {
ZStack {
if episode.userData?.isPlayed ?? false {
WatchedIndicator(size: 45)
} else if (episode.userData?.playbackPositionTicks ?? 0) > 0 {
if let progressLabel = episode.progressLabel {
LandscapePosterProgressBar(
title: episode.progressLabel ?? L10n.continue,
title: progressLabel,
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 {
Expand Down Expand Up @@ -64,7 +73,7 @@ extension SeriesEpisodeSelector {
SystemImageContentView(systemName: episode.systemImage)
}

imageOverlay
overlayView
}
.posterStyle(.landscape)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import JellyfinAPI
import SwiftUI

extension SeriesEpisodeSelector {

struct EpisodeContent: View {

@Default(.accentColor)
private var accentColor

Expand All @@ -26,6 +28,7 @@ extension SeriesEpisodeSelector {
Text(subHeader)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}

@ViewBuilder
Expand All @@ -46,6 +49,7 @@ extension SeriesEpisodeSelector {
.multilineTextAlignment(.leading)
.backport
.lineLimit(3, reservesSpace: true)
.font(.caption.weight(.light))
}

var body: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ extension SeriesEpisodeSelector {

let playButtonItem: BaseItemDto?

// MARK: - Content View

private func contentView(viewModel: SeasonItemViewModel) -> some View {
CollectionHStack(
uniqueElements: viewModel.elements,
Expand All @@ -53,30 +55,49 @@ extension SeriesEpisodeSelector {

lastFocusedEpisodeID = playButtonItem?.id

// good enough?
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
guard let playButtonItem else { return }
proxy.scrollTo(element: playButtonItem, animated: false)
}
}
}

// MARK: - Body

var body: some View {
WrappedView {
ZStack {
switch viewModel.state {
case .content:
contentView(viewModel: viewModel)
if viewModel.elements.isEmpty {
EmptyHStack(focusedEpisodeID: $focusedEpisodeID)
} else {
contentView(viewModel: viewModel)
}
case let .error(error):
ErrorHStack(viewModel: viewModel, error: error)
ErrorHStack(viewModel: viewModel, error: error, focusedEpisodeID: $focusedEpisodeID)
case .initial, .refreshing:
LoadingHStack()
LoadingHStack(focusedEpisodeID: $focusedEpisodeID)
}
}
.padding(.bottom, 45)
.focusSection()
.focusGuide(
focusGuide,
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"
)
.onChange(of: viewModel.id) {
Expand All @@ -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 {

@ObservedObject
var viewModel: SeasonItemViewModel

let error: JellyfinAPIError
let focusedEpisodeID: FocusState<String?>.Binding

var body: some View {
CollectionHStack(
Expand All @@ -110,21 +155,29 @@ extension SeriesEpisodeSelector {
.onSelect {
viewModel.send(.refresh)
}
.focused(focusedEpisodeID, equals: "ErrorCard")
.padding(.horizontal, 4)
}
.allowScrolling(false)
.insets(horizontal: EdgeInsets.edgePadding)
.itemSpacing(EdgeInsets.edgePadding / 2)
}
}

// MARK: - Loading HStack

struct LoadingHStack: View {

let focusedEpisodeID: FocusState<String?>.Binding

var body: some View {
CollectionHStack(
count: Int.random(in: 2 ..< 5),
count: 1,
columns: 3.5
) { _ in
SeriesEpisodeSelector.LoadingCard()
.focused(focusedEpisodeID, equals: "LoadingCard")
.padding(.horizontal, 4)
}
.allowScrolling(false)
.insets(horizontal: EdgeInsets.edgePadding)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,26 @@ extension SeriesEpisodeSelector {
}

var body: some View {
Button {
onSelect()
} label: {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Button {
onSelect()
} label: {
Color.secondarySystemFill
.opacity(0.75)
.posterStyle(.landscape)
.overlay {
Image(systemName: "arrow.clockwise.circle.fill")
Image(systemName: "arrow.clockwise")
.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
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,36 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Foundation
import JellyfinAPI
import SwiftUI

extension SeriesEpisodeSelector {

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 {
VStack(alignment: .leading) {
Color.secondarySystemFill
.opacity(0.75)
.posterStyle(.landscape)
Button {
onSelect()
} label: {
Color.secondarySystemFill
.opacity(0.75)
.posterStyle(.landscape)
.overlay {
ProgressView()
}
}
.buttonStyle(.card)
.posterShadow()

SeriesEpisodeSelector.EpisodeContent(
subHeader: String.random(count: 7 ..< 12),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,29 @@ import SwiftUI

struct SeriesEpisodeSelector: View {

// MARK: - Observed & Environment Objects

@ObservedObject
var viewModel: SeriesItemViewModel

@EnvironmentObject
private var parentFocusGuide: FocusGuide

// MARK: - State Variables

@State
private var didSelectPlayButtonSeason = false
@State
private var selection: SeasonItemViewModel.ID?

// MARK: - Calculated Variables

private var selectionViewModel: SeasonItemViewModel? {
viewModel.seasons.first(where: { $0.id == selection })
}

// MARK: - Body

var body: some View {
VStack(spacing: 0) {
SeasonsHStack(viewModel: viewModel, selection: $selection)
Expand All @@ -35,8 +43,6 @@ struct SeriesEpisodeSelector: View {
if let selectionViewModel {
EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem)
.environmentObject(parentFocusGuide)
} else {
LoadingHStack()
}
}
.onReceive(viewModel.playButtonItem.publisher) { newValue in
Expand Down
Loading

0 comments on commit 4f18463

Please sign in to comment.