Refactor Search and implement in tvOS (#539)

This commit is contained in:
Ethan Pippin 2022-08-27 21:30:17 -06:00 committed by GitHub
parent 5d0f933a2c
commit 98a5507b52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 451 additions and 659 deletions

View File

@ -19,8 +19,6 @@ final class LibraryCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
@Route(.push)
var search = makeSearch
@Route(.modal)
var filter = makeFilter
@ -45,10 +43,6 @@ final class LibraryCoordinator: NavigationCoordinatable {
LibraryView(viewModel: self.viewModel)
}
func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator {
SearchCoordinator(viewModel: viewModel)
}
func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(
filters: params.filters,

View File

@ -17,8 +17,6 @@ final class LibraryListCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
@Route(.push)
var search = makeSearch
@Route(.push)
var library = makeLibrary
#if os(iOS)
@Route(.push)
@ -35,10 +33,6 @@ final class LibraryListCoordinator: NavigationCoordinatable {
LibraryCoordinator(viewModel: params.viewModel, title: params.title)
}
func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator {
SearchCoordinator(viewModel: viewModel)
}
#if os(iOS)
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()

View File

@ -13,11 +13,14 @@ import SwiftUI
final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [
\MainTabCoordinator.home,
\MainTabCoordinator.search,
\MainTabCoordinator.allMedia,
])
@Route(tabItem: makeHomeTab, onTapped: onHomeTapped)
var home = makeHome
@Route(tabItem: makeSearchTab, onTapped: onSearchTapped)
var search = makeSearch
@Route(tabItem: makeAllMediaTab, onTapped: onMediaTapped)
var allMedia = makeAllMedia
@ -37,6 +40,22 @@ final class MainTabCoordinator: TabCoordinatable {
L10n.home.text
}
func makeSearch() -> NavigationViewCoordinator<SearchCoordinator> {
NavigationViewCoordinator(SearchCoordinator())
}
func onSearchTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator<SearchCoordinator>) {
if isRepeat {
coordinator.child.popToRoot()
}
}
@ViewBuilder
func makeSearchTab(isActive: Bool) -> some View {
Image(systemName: "magnifyingglass")
L10n.search.text
}
func makeAllMedia() -> NavigationViewCoordinator<LibraryListCoordinator> {
NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel()))
}

View File

@ -15,6 +15,7 @@ final class MainTabCoordinator: TabCoordinatable {
\MainTabCoordinator.home,
\MainTabCoordinator.tv,
\MainTabCoordinator.movies,
\MainTabCoordinator.search,
\MainTabCoordinator.other,
\MainTabCoordinator.settings,
])
@ -25,6 +26,8 @@ final class MainTabCoordinator: TabCoordinatable {
var tv = makeTv
@Route(tabItem: makeMoviesTab)
var movies = makeMovies
@Route(tabItem: makeSearchTab)
var search = makeSearch
@Route(tabItem: makeOtherTab)
var other = makeOther
@Route(tabItem: makeSettingsTab)
@ -66,6 +69,18 @@ final class MainTabCoordinator: TabCoordinatable {
}
}
func makeSearch() -> NavigationViewCoordinator<SearchCoordinator> {
NavigationViewCoordinator(SearchCoordinator())
}
@ViewBuilder
func makeSearchTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "magnifyingglass")
L10n.search.text
}
}
func makeOther() -> NavigationViewCoordinator<LibraryListCoordinator> {
NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel()))
}

View File

@ -17,21 +17,26 @@ final class SearchCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
#if os(tvOS)
@Route(.modal)
var item = makeItem
#else
@Route(.push)
var item = makeItem
#endif
let viewModel: LibrarySearchViewModel
init(viewModel: LibrarySearchViewModel) {
self.viewModel = viewModel
#if os(tvOS)
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
#endif
@ViewBuilder
func makeStart() -> some View {
LibrarySearchView(viewModel: self.viewModel)
SearchView(viewModel: .init())
}
}

View File

@ -42,4 +42,16 @@ extension String {
let initials = self.split(separator: " ").compactMap(\.first)
return String(initials)
}
func heightOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let textSize = self.size(withAttributes: fontAttributes)
return textSize.height
}
func widthOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let textSize = self.size(withAttributes: fontAttributes)
return textSize.width
}
}

View File

@ -36,6 +36,7 @@ extension Defaults.Keys {
static let recentlyAddedPosterType = Key<PosterType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let recommendedPosterType = Key<PosterType>("recommendedPosterType", default: .portrait, suite: .generalSuite)
static let searchPosterType = Key<PosterType>("searchPosterType", default: .portrait, suite: .generalSuite)
static let libraryPosterType = Key<PosterType>("libraryPosterType", default: .portrait, suite: .generalSuite)
enum Episodes {

View File

@ -1,179 +0,0 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Combine
import CombineExt
import Foundation
import JellyfinAPI
import SwiftUI
final class LibrarySearchViewModel: ViewModel {
@Published
var supportedItemTypeList = [ItemType]()
@Published
var selectedItemType: ItemType = .movie
@Published
var movieItems = [BaseItemDto]()
@Published
var showItems = [BaseItemDto]()
@Published
var episodeItems = [BaseItemDto]()
@Published
var suggestions = [BaseItemDto]()
var searchQuerySubject = CurrentValueSubject<String, Never>("")
var parentID: String?
init(parentID: String?) {
self.parentID = parentID
super.init()
searchQuerySubject
.filter { !$0.isEmpty }
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink(receiveValue: search)
.store(in: &cancellables)
setupPublishersForSupportedItemType()
requestSuggestions()
}
func setupPublishersForSupportedItemType() {
Publishers.CombineLatest3($movieItems, $showItems, $episodeItems)
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.map { arg -> [ItemType] in
var typeList = [ItemType]()
if !arg.0.isEmpty {
typeList.append(.movie)
}
if !arg.1.isEmpty {
typeList.append(.series)
}
if !arg.2.isEmpty {
typeList.append(.episode)
}
return typeList
}
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] typeList in
withAnimation {
self?.supportedItemTypeList = typeList
}
})
.store(in: &cancellables)
$supportedItemTypeList
.receive(on: DispatchQueue.main)
.withLatestFrom($selectedItemType)
.compactMap { selectedItemType in
if self.supportedItemTypeList.contains(selectedItemType) {
return selectedItemType
} else {
return self.supportedItemTypeList.first
}
}
.sink(receiveValue: { [weak self] itemType in
withAnimation {
self?.selectedItemType = itemType
}
})
.store(in: &cancellables)
}
func requestSuggestions() {
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 20,
recursive: true,
parentId: parentID,
includeItemTypes: [.movie, .series],
sortBy: ["IsFavoriteOrLiked", "Random"],
imageTypeLimit: 0,
enableTotalRecordCount: false,
enableImages: false
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.suggestions = response.items ?? []
})
.store(in: &cancellables)
}
func search(with query: String) {
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.movie],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.movieItems = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.series],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.showItems = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.episode],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.episodeItems = response.items ?? []
})
.store(in: &cancellables)
}
}

View File

@ -0,0 +1,129 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import JellyfinAPI
import SwiftUI
final class SearchViewModel: ViewModel {
private var searchCancellables = Set<AnyCancellable>()
@Published
var movies: [BaseItemDto] = []
@Published
var collections: [BaseItemDto] = []
@Published
var series: [BaseItemDto] = []
@Published
var episodes: [BaseItemDto] = []
@Published
var people: [BaseItemDto] = []
@Published
var suggestions: [BaseItemDto] = []
var noResults: Bool {
movies.isEmpty &&
collections.isEmpty &&
series.isEmpty &&
episodes.isEmpty &&
people.isEmpty
}
private var searchTextSubject = CurrentValueSubject<String, Never>("")
override init() {
super.init()
getSuggestions()
searchTextSubject
.handleEvents(receiveOutput: { _ in self.cancelPreviousSearch() })
.filter { !$0.isEmpty }
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink(receiveValue: _search)
.store(in: &cancellables)
}
private func cancelPreviousSearch() {
searchCancellables.forEach { $0.cancel() }
print(searchCancellables.count)
}
func search(with query: String) {
searchTextSubject.send(query)
}
private func _search(with query: String) {
getItems(with: query, for: .movie, keyPath: \.movies)
getItems(with: query, for: .boxSet, keyPath: \.collections)
getItems(with: query, for: .series, keyPath: \.series)
getItems(with: query, for: .episode, keyPath: \.episodes)
getPeople(with: query)
}
private func getItems(
with query: String,
for itemType: BaseItemKind,
keyPath: ReferenceWritableKeyPath<SearchViewModel, [BaseItemDto]>
) {
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 20,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
fields: ItemFields.allCases,
includeItemTypes: [itemType],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?[keyPath: keyPath] = response.items ?? []
})
.store(in: &searchCancellables)
}
private func getPeople(with query: String) {
PersonsAPI.getPersons(
limit: 20,
searchTerm: query
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.people = response.items ?? []
})
.store(in: &searchCancellables)
}
private func getSuggestions() {
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 10,
recursive: true,
includeItemTypes: [.movie, .series],
sortBy: ["IsFavoriteOrLiked", "Random"],
imageTypeLimit: 0,
enableTotalRecordCount: false,
enableImages: false
)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.suggestions = response.items ?? []
})
.store(in: &cancellables)
}
}

View File

@ -8,6 +8,7 @@
import SwiftUI
// TODO: Replace with `attributeStyle`
struct AttributeFillView: View {
let text: String

View File

@ -8,6 +8,7 @@
import SwiftUI
// TODO: Replace with `attributeStyle`
struct AttributeOutlineView: View {
let text: String

View File

@ -1,38 +0,0 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct SearchBar: View {
@Binding
var text: String
@State
private var isEditing = false
var body: some View {
HStack(spacing: 8) {
TextField(L10n.searchDots, text: $text)
.padding(8)
.padding(.horizontal, 16)
#if os(iOS)
.background(Color(.systemGray6))
#endif
.cornerRadius(8)
if !text.isEmpty {
Button(action: {
self.text = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 16)
}
}

View File

@ -1,77 +0,0 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
private struct SearchablePickerView<Selectable: Hashable>: View {
@Environment(\.presentationMode)
var presentationMode
let options: [Selectable]
let optionToString: (Selectable) -> String
let label: String
@State
var text = ""
@Binding
var selected: Selectable
var body: some View {
VStack {
SearchBar(text: $text)
List(options.filter {
guard !text.isEmpty else { return true }
return optionToString($0).lowercased().contains(text.lowercased())
}, id: \.self) { selectable in
Button(action: {
selected = selectable
presentationMode.wrappedValue.dismiss()
}) {
HStack {
Text(optionToString(selectable)).foregroundColor(Color.primary)
Spacer()
if selected == selectable {
Image(systemName: "checkmark").foregroundColor(.accentColor)
}
}
}
}.listStyle(GroupedListStyle())
}
}
}
struct SearchablePicker<Selectable: Hashable>: View {
let label: String
let options: [Selectable]
let optionToString: (Selectable) -> String
@Binding
var selected: Selectable
var body: some View {
NavigationLink(destination: searchablePickerView()) {
HStack {
Text(label)
Spacer()
Text(optionToString(selected))
.foregroundColor(.gray)
.multilineTextAlignment(.trailing)
}
}
}
private func searchablePickerView() -> some View {
SearchablePickerView(
options: options,
optionToString: optionToString,
label: label,
selected: $selected
)
}
}

View File

@ -8,40 +8,6 @@
import SwiftUI
extension TruncatedTextView {
func font(_ font: Font) -> TruncatedTextView {
var result = self
result.font = font
return result
}
func lineLimit(_ lineLimit: Int) -> TruncatedTextView {
var result = self
result.lineLimit = lineLimit
return result
}
func foregroundColor(_ color: Color) -> TruncatedTextView {
var result = self
result.foregroundColor = color
return result
}
}
extension String {
func heightOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let textSize = self.size(withAttributes: fontAttributes)
return textSize.height
}
func widthOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let textSize = self.size(withAttributes: fontAttributes)
return textSize.width
}
}
struct TruncatedTextView: View {
@State
@ -144,3 +110,23 @@ struct TruncatedTextView: View {
}
}
}
extension TruncatedTextView {
func font(_ font: Font) -> Self {
var result = self
result.font = font
return result
}
func lineLimit(_ lineLimit: Int) -> Self {
var result = self
result.lineLimit = lineLimit
return result
}
func foregroundColor(_ color: Color) -> Self {
var result = self
result.foregroundColor = color
return result
}
}

View File

@ -9,7 +9,7 @@
import JellyfinAPI
import SwiftUI
// TODO: Transition to `PortraitButton`
// TODO: Transition to PosterButton`
struct PortraitItemElement: View {
@Environment(\.isFocused)

View File

@ -29,6 +29,8 @@ struct ItemView: View {
SeriesItemView(viewModel: .init(item: item))
case .boxSet:
CollectionItemView(viewModel: .init(item: item))
case .person:
LibraryView(viewModel: .init(person: .init(id: item.id)))
default:
Text(L10n.notImplementedYetWithType(item.type ?? "--"))
}

View File

@ -1,107 +0,0 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import SwiftUI
struct LibrarySearchView: View {
@StateObject
var viewModel: LibrarySearchViewModel
@State
var searchQuery = ""
@State
private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125)
func recalcTracks() {
tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125)
}
var body: some View {
ZStack {
VStack {
SearchBar(text: $searchQuery)
.padding(.top, 16)
.padding(.bottom, 8)
if searchQuery.isEmpty {
suggestionsListView
} else {
resultView
}
}
if viewModel.isLoading {
ProgressView()
}
}
.onChange(of: searchQuery) { query in
viewModel.searchQuerySubject.send(query)
}
.navigationBarTitle(L10n.search)
}
var suggestionsListView: some View {
ScrollView {
LazyVStack(spacing: 8) {
L10n.suggestions.text
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.primary)
.padding(.bottom, 8)
ForEach(viewModel.suggestions, id: \.id) { item in
Button {
searchQuery = item.name ?? ""
} label: {
Text(item.name ?? "")
.font(.body)
}
}
}
.padding(.horizontal, 16)
}
}
var resultView: some View {
let items = items(for: viewModel.selectedItemType)
return VStack(alignment: .leading, spacing: 16) {
Picker("ItemType", selection: $viewModel.selectedItemType) {
ForEach(viewModel.supportedItemTypeList, id: \.self) {
Text($0.localized)
.tag($0)
}
}
.pickerStyle(SegmentedPickerStyle())
.padding(.horizontal, 16)
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if !items.isEmpty {
LazyVGrid(columns: tracks) {
ForEach(items, id: \.id) { item in
ItemView(item: item)
}
}
.padding(.bottom, 16)
}
}
}
}
}
func items(for type: ItemType) -> [BaseItemDto] {
switch type {
case .episode:
return viewModel.episodeItems
case .movie:
return viewModel.movieItems
case .series:
return viewModel.showItems
default:
return []
}
}
}

View File

@ -11,6 +11,7 @@ import SwiftUI
import SwiftUICollection
struct MovieLibrariesView: View {
@EnvironmentObject
private var movieLibrariesRouter: MovieLibrariesCoordinator.Router
@StateObject

View File

@ -0,0 +1,96 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct SearchView: View {
@EnvironmentObject
private var router: SearchCoordinator.Router
@ObservedObject
var viewModel: SearchViewModel
@State
private var searchText = ""
@ViewBuilder
private var suggestionsView: some View {
VStack(spacing: 20) {
ForEach(viewModel.suggestions, id: \.id) { item in
Button {
searchText = item.displayName
} label: {
Text(item.displayName)
.font(.body)
}
}
}
}
@ViewBuilder
private var resultsView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
if !viewModel.movies.isEmpty {
itemsSection(title: L10n.movies, keyPath: \.movies)
}
if !viewModel.collections.isEmpty {
// TODO: Localize after organization
itemsSection(title: "Collections", keyPath: \.collections)
}
if !viewModel.series.isEmpty {
itemsSection(title: L10n.tvShows, keyPath: \.series)
}
if !viewModel.episodes.isEmpty {
itemsSection(title: L10n.episodes, keyPath: \.episodes)
}
if !viewModel.people.isEmpty {
// TODO: Localize after organization
itemsSection(title: "People", keyPath: \.people)
}
}
}
.ignoresSafeArea()
}
@ViewBuilder
private func itemsSection(
title: String,
keyPath: ReferenceWritableKeyPath<SearchViewModel, [BaseItemDto]>
) -> some View {
PosterHStack(
title: title,
type: .portrait,
items: viewModel[keyPath: keyPath]
)
.onSelect { item in
router.route(to: \.item, item)
}
}
var body: some View {
Group {
if searchText.isEmpty {
EmptyView()
} else if !viewModel.isLoading && viewModel.noResults {
L10n.noResults.text
} else {
resultsView
}
}
.onChange(of: searchText) { newText in
viewModel.search(with: newText)
}
.searchable(text: $searchText, prompt: L10n.search)
}
}

View File

@ -83,10 +83,8 @@
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; };
53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; };
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; };
53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; };
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; };
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; };
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; };
53EE24E6265060780068F029 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* SearchView.swift */; };
5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; };
5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; };
5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; };
@ -106,7 +104,6 @@
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; };
62400C4B287ED19600F6AD3D /* UDPBroadcast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; };
62400C4C287ED19600F6AD3D /* UDPBroadcast.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; };
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; };
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; };
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; };
@ -160,8 +157,8 @@
62E1DCC3273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; };
62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; };
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; };
62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; };
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; };
62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; };
62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; };
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; };
62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; };
62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; };
@ -224,7 +221,6 @@
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; };
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; };
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; };
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; };
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; };
E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; };
E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; };
@ -269,7 +265,6 @@
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; };
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; };
E12186DE2718F1C50010884C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E12186DD2718F1C50010884C /* Defaults */; };
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1218C9D271A2CD600EA0737 /* CombineExt */; };
E122A9132788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */; };
E122A9142788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */; };
E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */; };
@ -372,14 +367,11 @@
E18E0207288749200022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; };
E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; };
E18E021A2887492B0022598C /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0200288749200022598C /* AppIcon.swift */; };
E18E021B2887492B0022598C /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; };
E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; };
E18E021D2887492B0022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; };
E18E021E2887492B0022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; };
E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; };
E18E02202887492B0022598C /* AttributeFillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0201288749200022598C /* AttributeFillView.swift */; };
E18E02212887492B0022598C /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; };
E18E02222887492B0022598C /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; };
E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; };
E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollViewExtensions.swift */; };
@ -432,7 +424,6 @@
E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; };
E1B2AB9928808E150072B3B9 /* GoogleCast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */; };
E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */; };
E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; };
E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; };
E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */; };
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; };
@ -484,6 +475,9 @@
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; };
E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; };
E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; };
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643928BAC2EF00323B0A /* SearchView.swift */; };
E1E1643E28BB074000323B0A /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643D28BB074000323B0A /* MultiSelectorView.swift */; };
E1E1643F28BB075C00323B0A /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643D28BB074000323B0A /* MultiSelectorView.swift */; };
E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; };
E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; };
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; };
@ -618,11 +612,9 @@
53ABFDEA2679753200886593 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
53CD2A3F268A49C2002ABD4E /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = "<group>"; };
53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = "<group>"; };
53DE4BD1267098F300739748 /* SearchBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarView.swift; sourceTree = "<group>"; };
53DF641D263D9C0600A7CD1A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = "<group>"; };
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = "<group>"; };
53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
53EE24E5265060780068F029 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = "<group>"; };
5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = "<group>"; };
5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = "<group>"; };
@ -636,7 +628,6 @@
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = "<group>"; };
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = "<group>"; };
624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = "<group>"; };
625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = "<group>"; };
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerViewModel.swift; sourceTree = "<group>"; };
@ -681,7 +672,7 @@
62C83B07288C6A630004ED0C /* FontPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPicker.swift; sourceTree = "<group>"; };
62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensions.swift; sourceTree = "<group>"; };
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = "<group>"; };
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = "<group>"; };
62E632DB267D2E130063E547 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemViewModel.swift; sourceTree = "<group>"; };
62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemViewModel.swift; sourceTree = "<group>"; };
@ -725,7 +716,6 @@
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = "<group>"; };
C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = "<group>"; };
C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = "<group>"; };
C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = "<group>"; };
@ -899,6 +889,8 @@
E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = "<group>"; };
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
E1E00A34278628A40022235B /* DoubleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleExtensions.swift; sourceTree = "<group>"; };
E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
E1E1643D28BB074000323B0A /* MultiSelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = "<group>"; };
E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = "<group>"; };
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = "<group>"; };
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = "<group>"; };
@ -926,7 +918,6 @@
C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */,
62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */,
62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */,
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */,
62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */,
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */,
62666E1D27E501DB00EC0ECD /* CoreMedia.framework in Frameworks */,
@ -995,7 +986,6 @@
62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */,
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */,
62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */,
E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */,
E1347DB2279E3C6200BC6161 /* Puppy in Frameworks */,
E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */,
);
@ -1039,11 +1029,12 @@
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */,
62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */,
625CB5742678C33500530A6E /* LibraryListViewModel.swift */,
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */,
62E632DF267D30CA0063E547 /* LibraryViewModel.swift */,
C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */,
C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */,
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */,
6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */,
62E632DB267D2E130063E547 /* SearchViewModel.swift */,
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */,
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
@ -1053,7 +1044,6 @@
09389CC626819B4500AE350E /* VideoPlayerModel.swift */,
E126F73F278A655300A522BF /* VideoPlayerViewModel */,
625CB57B2678CE1000530A6E /* ViewModel.swift */,
6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -1623,16 +1613,16 @@
E1B59FD62786AE2C00A5287E /* ContinueWatchingView */,
531690E6267ABD79005D8AB9 /* HomeView.swift */,
E193D54E271942C000900D82 /* ItemView */,
E193D54C2719426600900D82 /* LibraryFilterView.swift */,
E1C925F828875647002A7A66 /* LatestInLibraryView.swift */,
E193D54C2719426600900D82 /* LibraryFilterView.swift */,
C4E508172703E8190045C9AB /* LibraryListView.swift */,
C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */,
53A83C32268A309300DF3D92 /* LibraryView.swift */,
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */,
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */,
C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */,
C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */,
C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */,
C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */,
E1E1643928BAC2EF00323B0A /* SearchView.swift */,
E193D54F2719430400900D82 /* ServerDetailView.swift */,
E193D54A271941D300900D82 /* ServerListView.swift */,
E1E5D54D2783E66600692DFE /* SettingsView */,
@ -1684,7 +1674,6 @@
E14F7D0A26DB3714007C3AE6 /* ItemView */,
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */,
6213388F265F83A900A81A2A /* LibraryListView.swift */,
53EE24E5265060780068F029 /* LibrarySearchView.swift */,
53DF641D263D9C0600A7CD1A /* LibraryView.swift */,
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */,
C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */,
@ -1692,6 +1681,7 @@
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */,
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */,
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */,
53EE24E5265060780068F029 /* SearchView.swift */,
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
E13DD3E427177D15009D4DAF /* ServerListView.swift */,
E1E5D54A2783E26100692DFE /* SettingsView */,
@ -1994,10 +1984,8 @@
E18E01FF288749200022598C /* Divider.swift */,
531AC8BE26750DE20091C7EB /* ImageView.swift */,
E1047E2227E5880000CB0D4A /* InitialFailureView.swift */,
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */,
E1E1643D28BB074000323B0A /* MultiSelectorView.swift */,
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
624C21742685CF60007F1390 /* SearchablePickerView.swift */,
53DE4BD1267098F300739748 /* SearchBarView.swift */,
E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */,
);
path = Views;
@ -2145,7 +2133,6 @@
6220D0C826D63F3700B8E046 /* Stinsen */,
E13DD3CC27164CA7009D4DAF /* CoreStore */,
E12186DD2718F1C50010884C /* Defaults */,
E1218C9D271A2CD600EA0737 /* CombineExt */,
E178857C278037FD0094FBCF /* JellyfinAPI */,
E1002B6A2793E36600E47059 /* Algorithms */,
E1347DB5279E3CA500BC6161 /* Puppy */,
@ -2183,7 +2170,6 @@
62C29E9B26D0FE4200C1D2E7 /* Stinsen */,
E13DD3C52716499E009D4DAF /* CoreStore */,
E13DD3D227168E65009D4DAF /* Defaults */,
E1B6DCE7271A23780015B715 /* CombineExt */,
E1B6DCE9271A23880015B715 /* SwiftyJSON */,
E10EAA44277BB646000269ED /* JellyfinAPI */,
E10EAA4C277BB716000269ED /* Sliders */,
@ -2250,7 +2236,6 @@
62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */,
E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */,
E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */,
E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */,
E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */,
@ -2384,17 +2369,16 @@
C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */,
E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */,
C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */,
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */,
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
E13DD3F027178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */,
E1A16CA1288A7CFD00EA4679 /* AboutViewCard.swift in Sources */,
E1937A3F288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */,
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
E1E1643E28BB074000323B0A /* MultiSelectorView.swift in Sources */,
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
5D32EA12278C95E30020E292 /* VLCPlayer+subtitles.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
E18E02212887492B0022598C /* MultiSelectorView.swift in Sources */,
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */,
@ -2475,13 +2459,11 @@
E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */,
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
E18E02222887492B0022598C /* SearchablePickerView.swift in Sources */,
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */,
536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */,
5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */,
E18E021B2887492B0022598C /* SearchBarView.swift in Sources */,
E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */,
62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */,
E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
@ -2514,6 +2496,7 @@
E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */,
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */,
E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */,
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */,
E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */,
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */,
E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */,
@ -2581,7 +2564,7 @@
62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */,
62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */,
62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */,
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */,
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */,
@ -2589,6 +2572,7 @@
E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */,
E18E0208288749200022598C /* BlurView.swift in Sources */,
E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */,
E1E1643F28BB075C00323B0A /* MultiSelectorView.swift in Sources */,
E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */,
E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */,
C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */,
@ -2685,12 +2669,10 @@
E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */,
E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */,
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */,
53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */,
E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */,
E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */,
E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */,
E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */,
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */,
E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */,
6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */,
E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
@ -2720,7 +2702,6 @@
E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */,
E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */,
E18E01E9288747230022598C /* SeriesItemView.swift in Sources */,
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */,
E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */,
E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */,
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */,
@ -2764,7 +2745,7 @@
5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */,
E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */,
E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */,
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */,
53EE24E6265060780068F029 /* SearchView.swift in Sources */,
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -3256,14 +3237,6 @@
minimumVersion = 0.5.0;
};
};
E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/CombineCommunity/CombineExt";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
};
};
E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/LePips/Puppy";
@ -3398,11 +3371,6 @@
package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
E1218C9D271A2CD600EA0737 /* CombineExt */ = {
isa = XCSwiftPackageProductDependency;
package = E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */;
productName = CombineExt;
};
E1347DB1279E3C6200BC6161 /* Puppy */ = {
isa = XCSwiftPackageProductDependency;
package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */;
@ -3478,11 +3446,6 @@
package = E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */;
productName = BlurHashKit;
};
E1B6DCE7271A23780015B715 /* CombineExt */ = {
isa = XCSwiftPackageProductDependency;
package = E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */;
productName = CombineExt;
};
E1B6DCE9271A23880015B715 /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency;
package = E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;

View File

@ -36,15 +36,6 @@
"revision" : "1dbf31d860626f8debdbb08201517a4684d226c6"
}
},
{
"identity" : "combineext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CombineCommunity/CombineExt",
"state" : {
"revision" : "581849239060948b626d1173eb0e5926818e7f8c",
"version" : "1.7.0"
}
},
{
"identity" : "corestore",
"kind" : "remoteSourceControl",

View File

@ -42,6 +42,8 @@ struct ItemView: View {
} else {
CollectionItemView(viewModel: .init(item: item))
}
case .person:
LibraryView(viewModel: .init(person: .init(id: item.id)))
default:
Text(L10n.notImplementedYetWithType(item.type ?? "--"))
}

View File

@ -89,14 +89,5 @@ struct LibraryListView: View {
.padding(.top, 8)
}
.navigationTitle(L10n.allMedia)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil))
} label: {
Image(systemName: "magnifyingglass")
}
}
}
}
}

View File

@ -1,114 +0,0 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import Stinsen
import SwiftUI
struct LibrarySearchView: View {
@EnvironmentObject
private var searchRouter: SearchCoordinator.Router
@StateObject
var viewModel: LibrarySearchViewModel
@State
private var searchQuery = ""
@State
private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125)
func recalcTracks() {
tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125)
}
var body: some View {
ZStack {
VStack {
SearchBar(text: $searchQuery)
.padding(.top, 16)
.padding(.bottom, 8)
if searchQuery.isEmpty {
suggestionsListView
} else {
resultView
}
}
if viewModel.isLoading {
ProgressView()
}
}
.onChange(of: searchQuery) { query in
viewModel.searchQuerySubject.send(query)
}
.navigationBarTitle(L10n.search, displayMode: .inline)
}
var suggestionsListView: some View {
ScrollView {
LazyVStack(spacing: 8) {
L10n.suggestions.text
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.primary)
.padding(.bottom, 8)
ForEach(viewModel.suggestions, id: \.id) { item in
Button {
searchQuery = item.name ?? ""
} label: {
Text(item.name ?? "")
.font(.body)
}
}
}
.padding(.horizontal, 16)
}
}
var resultView: some View {
let items = items(for: viewModel.selectedItemType)
return VStack(alignment: .leading, spacing: 16) {
Picker("ItemType", selection: $viewModel.selectedItemType) {
ForEach(viewModel.supportedItemTypeList, id: \.self) {
Text($0.localized)
.tag($0)
}
}
.pickerStyle(SegmentedPickerStyle())
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if !items.isEmpty {
LazyVGrid(columns: tracks) {
ForEach(items, id: \.id) { item in
PosterButton(item: item, type: .portrait)
.onSelect { item in
searchRouter.route(to: \.item, item)
}
}
}
}
}
}
}
.onRotate { _ in
recalcTracks()
}
}
func items(for type: ItemType) -> [BaseItemDto] {
switch type {
case .episode:
return viewModel.episodeItems
case .movie:
return viewModel.movieItems
case .series:
return viewModel.showItems
default:
return []
}
}
}

View File

@ -18,7 +18,7 @@ struct LibraryView: View {
var viewModel: LibraryViewModel
@Default(.Customization.libraryPosterType)
var libraryPosterType
private var libraryPosterType
@ViewBuilder
private var loadingView: some View {
@ -89,12 +89,6 @@ struct LibraryView: View {
Image(systemName: "line.horizontal.3.decrease.circle")
}
.foregroundColor(viewModel.filters == .default ? .accentColor : Color(UIColor.systemOrange))
Button {
libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID))
} label: {
Image(systemName: "magnifyingglass")
}
}
}
}

View File

@ -0,0 +1,102 @@
//
// 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) 2022 Jellyfin & Jellyfin Contributors
//
import CollectionView
import Defaults
import JellyfinAPI
import SwiftUI
struct SearchView: View {
@EnvironmentObject
private var router: SearchCoordinator.Router
@ObservedObject
var viewModel: SearchViewModel
@Default(.Customization.searchPosterType)
private var searchPosterType
@State
private var searchText = ""
@ViewBuilder
private var suggestionsView: some View {
VStack(spacing: 20) {
ForEach(viewModel.suggestions, id: \.id) { item in
Button {
searchText = item.displayName
} label: {
Text(item.displayName)
.font(.body)
}
}
}
}
@ViewBuilder
private var resultsView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
if !viewModel.movies.isEmpty {
itemsSection(title: L10n.movies, keyPath: \.movies, posterType: searchPosterType)
}
if !viewModel.collections.isEmpty {
// TODO: Localize after organization
itemsSection(title: "Collections", keyPath: \.collections, posterType: searchPosterType)
}
if !viewModel.series.isEmpty {
itemsSection(title: L10n.tvShows, keyPath: \.series, posterType: searchPosterType)
}
if !viewModel.episodes.isEmpty {
itemsSection(title: L10n.episodes, keyPath: \.episodes, posterType: searchPosterType)
}
if !viewModel.people.isEmpty {
// TODO: Localize after organization
itemsSection(title: "People", keyPath: \.people, posterType: .portrait)
}
}
}
}
@ViewBuilder
private func itemsSection(
title: String,
keyPath: ReferenceWritableKeyPath<SearchViewModel, [BaseItemDto]>,
posterType: PosterType
) -> some View {
PosterHStack(
title: title,
type: posterType,
items: viewModel[keyPath: keyPath]
)
.onSelect { item in
router.route(to: \.item, item)
}
}
var body: some View {
Group {
if searchText.isEmpty {
suggestionsView
} else if !viewModel.isLoading && viewModel.noResults {
L10n.noResults.text
} else {
resultsView
}
}
.onChange(of: searchText) { newText in
viewModel.search(with: newText)
}
.searchable(text: $searchText, placement: .navigationBarDrawer, prompt: L10n.search)
.navigationTitle(L10n.search)
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -31,6 +31,8 @@ struct CustomizeViewsSettings: View {
var latestInLibraryPosterType
@Default(.Customization.recommendedPosterType)
var recommendedPosterType
@Default(.Customization.searchPosterType)
var searchPosterType
@Default(.Customization.libraryPosterType)
var libraryPosterType
@ -89,6 +91,12 @@ struct CustomizeViewsSettings: View {
// }
// }
Picker(L10n.search, selection: $searchPosterType) {
ForEach(PosterType.allCases, id: \.self) { type in
Text(type.localizedName).tag(type.rawValue)
}
}
Picker(L10n.library, selection: $libraryPosterType) {
ForEach(PosterType.allCases, id: \.self) { type in
Text(type.localizedName).tag(type.rawValue)