jellyflood/jellyflood tvOS/Views 2/SearchView.swift

200 lines
6.2 KiB
Swift

//
// 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 Defaults
import JellyfinAPI
import SwiftUI
struct SearchView: View {
@Default(.Customization.searchPosterType)
private var searchPosterType
@Router
private var router
@State
private var searchQuery = ""
@StateObject
private var viewModel = SearchViewModel()
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.search(query: searchQuery)
}
}
@ViewBuilder
private var suggestionsView: some View {
VStack(spacing: 20) {
ForEach(viewModel.suggestions) { item in
Button(item.displayTitle) {
searchQuery = item.displayTitle
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var resultsView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
if let movies = viewModel.items[.movie], movies.isNotEmpty {
itemsSection(
title: L10n.movies,
type: .movie,
items: movies,
posterType: searchPosterType
)
}
if let series = viewModel.items[.series], series.isNotEmpty {
itemsSection(
title: L10n.tvShows,
type: .series,
items: series,
posterType: searchPosterType
)
}
if let collections = viewModel.items[.boxSet], collections.isNotEmpty {
itemsSection(
title: L10n.collections,
type: .boxSet,
items: collections,
posterType: searchPosterType
)
}
if let episodes = viewModel.items[.episode], episodes.isNotEmpty {
itemsSection(
title: L10n.episodes,
type: .episode,
items: episodes,
posterType: searchPosterType
)
}
if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty {
itemsSection(
title: L10n.musicVideos,
type: .musicVideo,
items: musicVideos,
posterType: .landscape
)
}
if let videos = viewModel.items[.video], videos.isNotEmpty {
itemsSection(
title: L10n.videos,
type: .video,
items: videos,
posterType: .landscape
)
}
if let programs = viewModel.items[.program], programs.isNotEmpty {
itemsSection(
title: L10n.programs,
type: .program,
items: programs,
posterType: .landscape
)
}
if let channels = viewModel.items[.tvChannel], channels.isNotEmpty {
itemsSection(
title: L10n.channels,
type: .tvChannel,
items: channels,
posterType: .square
)
}
if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty {
itemsSection(
title: L10n.artists,
type: .musicArtist,
items: musicArtists,
posterType: .portrait
)
}
if let people = viewModel.items[.person], people.isNotEmpty {
itemsSection(
title: L10n.people,
type: .person,
items: people,
posterType: .portrait
)
}
}
.edgePadding(.vertical)
}
}
private func select(_ item: BaseItemDto) {
switch item.type {
case .program, .tvChannel:
let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession)
router.route(to: .videoPlayer(provider: provider))
default:
router.route(to: .item(item: item))
}
}
@ViewBuilder
private func itemsSection(
title: String,
type: BaseItemKind,
items: [BaseItemDto],
posterType: PosterDisplayType
) -> some View {
PosterHStack(
title: title,
type: posterType,
items: items,
action: select
)
}
var body: some View {
ZStack {
switch viewModel.state {
case .error:
viewModel.error.map { errorView(with: $0) }
case .initial:
if viewModel.hasNoResults {
if searchQuery.isEmpty {
suggestionsView
} else {
Text(L10n.noResults)
}
} else {
resultsView
}
case .searching:
ProgressView()
}
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea(edges: [.bottom, .horizontal])
.onFirstAppear {
viewModel.getSuggestions()
}
.onChange(of: searchQuery) { _, newValue in
viewModel.search(query: newValue)
}
.searchable(text: $searchQuery, prompt: L10n.search)
}
}