feat: for tvOS
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
"Bash(xargs -I{} sample {} 1)",
|
||||
"Bash(killall xcodebuild:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(pkill:*)"
|
||||
"Bash(pkill:*)",
|
||||
"Bash(git:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ struct BrowseView: View {
|
||||
.navigationDestination(for: ContentItem.self) { item in
|
||||
DetailView(item: item)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.refreshable {
|
||||
await viewModel.loadContent()
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
viewModel.category = category
|
||||
await viewModel.loadContentIfNeeded()
|
||||
|
||||
@@ -3,9 +3,17 @@ import SwiftUI
|
||||
struct ContentCardView: View {
|
||||
let item: ContentItem
|
||||
|
||||
#if os(tvOS)
|
||||
private let cardWidth: CGFloat = 250
|
||||
#else
|
||||
private let cardWidth: CGFloat = 140
|
||||
#endif
|
||||
private let aspectRatio: CGFloat = 2.0 / 3.0
|
||||
|
||||
#if os(tvOS)
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// 海报
|
||||
@@ -54,6 +62,11 @@ struct ContentCardView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: cardWidth)
|
||||
#if os(tvOS)
|
||||
.scaleEffect(isFocused ? 1.1 : 1.0)
|
||||
.shadow(color: isFocused ? .blue.opacity(0.4) : .clear, radius: 10)
|
||||
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -4,9 +4,15 @@ struct ContentGridView: View {
|
||||
let items: [ContentItem]
|
||||
var onNearEnd: (() -> Void)?
|
||||
|
||||
#if os(tvOS)
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 24)
|
||||
]
|
||||
#else
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
|
||||
]
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
|
||||
@@ -7,15 +7,47 @@ struct FilterBarView: View {
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
#if os(tvOS)
|
||||
filterPicker("排序", options: FilterState.defaultSorts, selection: $filter.sort)
|
||||
filterPicker("类型", options: FilterState.defaultGenres, selection: $filter.genre)
|
||||
filterPicker("地区", options: FilterState.defaultRegions, selection: $filter.region)
|
||||
filterPicker("年份", options: FilterState.defaultYears, selection: $filter.year)
|
||||
#else
|
||||
filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort)
|
||||
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
|
||||
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
|
||||
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private func filterPicker(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(options) { option in
|
||||
Button {
|
||||
selection.wrappedValue = option.value
|
||||
onFilterChanged()
|
||||
} label: {
|
||||
Text(option.name)
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
selection.wrappedValue == option.value
|
||||
? Color.accentColor
|
||||
: Color.secondary.opacity(0.12),
|
||||
in: Capsule()
|
||||
)
|
||||
.foregroundStyle(selection.wrappedValue == option.value ? .white : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
|
||||
Menu {
|
||||
ForEach(options) { option in
|
||||
@@ -52,4 +84,5 @@ struct FilterBarView: View {
|
||||
guard !value.isEmpty else { return nil }
|
||||
return options.first { $0.value == value }?.name
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ struct DetailView: View {
|
||||
@State private var showPlayer = false
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
@@ -32,7 +36,19 @@ struct DetailView: View {
|
||||
await viewModel.loadDetail(path: item.detailURL)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showPlayer) {
|
||||
if let episode = viewModel.currentEpisode {
|
||||
VideoPlayerView(
|
||||
url: episode.url,
|
||||
title: item.title,
|
||||
episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil,
|
||||
contentId: item.id,
|
||||
episodeId: episode.id
|
||||
)
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.sheet(isPresented: $showPlayer) {
|
||||
if let episode = viewModel.currentEpisode {
|
||||
VideoPlayerView(
|
||||
|
||||
@@ -5,9 +5,15 @@ struct EpisodeListView: View {
|
||||
let selectedIndex: Int
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
#if os(tvOS)
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8)
|
||||
]
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
@@ -31,6 +31,7 @@ struct HomeView: View {
|
||||
.navigationDestination(for: ContentItem.self) { item in
|
||||
DetailView(item: item)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.searchable(text: $searchText, prompt: "搜索电影、电视剧...")
|
||||
.onSubmit(of: .search) {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -52,6 +53,7 @@ struct HomeView: View {
|
||||
await viewModel.loadHome()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
await viewModel.loadHomeIfNeeded()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppTab: String, CaseIterable, Identifiable {
|
||||
enum AppTab: String, Identifiable {
|
||||
case home
|
||||
case movie
|
||||
case series
|
||||
case variety
|
||||
case anime
|
||||
case search
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -17,6 +18,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
case .series: return "电视剧"
|
||||
case .variety: return "综艺"
|
||||
case .anime: return "动漫"
|
||||
case .search: return "搜索"
|
||||
case .settings: return "设置"
|
||||
}
|
||||
}
|
||||
@@ -28,6 +30,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
case .series: return "tv"
|
||||
case .variety: return "theatermasks"
|
||||
case .anime: return "sparkles"
|
||||
case .search: return "magnifyingglass"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
@@ -41,6 +44,14 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static var visibleTabs: [AppTab] {
|
||||
#if os(tvOS)
|
||||
return [.home, .movie, .series, .variety, .anime, .search, .settings]
|
||||
#else
|
||||
return [.home, .movie, .series, .variety, .anime, .settings]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNavigation: View {
|
||||
@@ -55,6 +66,8 @@ struct AppNavigation: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
sidebarLayout
|
||||
#elseif os(tvOS)
|
||||
tabLayout
|
||||
#elseif os(visionOS)
|
||||
sidebarLayout
|
||||
#else
|
||||
@@ -68,7 +81,7 @@ struct AppNavigation: View {
|
||||
|
||||
private var tabLayout: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
ForEach(AppTab.visibleTabs) { tab in
|
||||
NavigationStack {
|
||||
tabContent(for: tab)
|
||||
}
|
||||
@@ -82,7 +95,7 @@ struct AppNavigation: View {
|
||||
|
||||
private var sidebarLayout: some View {
|
||||
NavigationSplitView {
|
||||
List(AppTab.allCases, selection: $selectedTab) { tab in
|
||||
List(AppTab.visibleTabs, selection: $selectedTab) { tab in
|
||||
Label(tab.title, systemImage: tab.icon)
|
||||
.tag(tab)
|
||||
}
|
||||
@@ -107,6 +120,12 @@ struct AppNavigation: View {
|
||||
BrowseView(category: .variety, viewModel: varietyVM)
|
||||
case .anime:
|
||||
BrowseView(category: .anime, viewModel: animeVM)
|
||||
case .search:
|
||||
#if os(tvOS)
|
||||
TVSearchView()
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ struct NativePlayerView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
let vc = AVPlayerViewController()
|
||||
vc.player = player
|
||||
#if !os(tvOS)
|
||||
vc.allowsPictureInPicturePlayback = true
|
||||
#endif
|
||||
return vc
|
||||
}
|
||||
|
||||
@@ -52,18 +54,43 @@ struct VideoPlayerView: View {
|
||||
|
||||
@State private var viewModel = PlayerViewModel()
|
||||
|
||||
#if os(iOS)
|
||||
#if !os(macOS)
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
macOSPlayer
|
||||
#elseif os(tvOS)
|
||||
tvOSPlayer
|
||||
#else
|
||||
iOSPlayer
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var tvOSPlayer: some View {
|
||||
Group {
|
||||
if let player = viewModel.player {
|
||||
NativePlayerView(player: player)
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.play(url: url, contentId: contentId, episodeId: episodeId)
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private var iOSPlayer: some View {
|
||||
ZStack {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
#if !os(tvOS)
|
||||
struct SearchView: View {
|
||||
@State private var viewModel = SearchViewModel()
|
||||
|
||||
@@ -59,3 +60,4 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
74
DDYSClient/Views/Search/TVSearchView.swift
Normal file
74
DDYSClient/Views/Search/TVSearchView.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
struct TVSearchView: View {
|
||||
@State private var viewModel = SearchViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// 搜索输入框
|
||||
HStack {
|
||||
TextField("搜索电影、电视剧...", text: $viewModel.query)
|
||||
.textFieldStyle(.plain)
|
||||
.onSubmit {
|
||||
Task { await viewModel.search() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// 搜索结果
|
||||
if viewModel.isSearching {
|
||||
ProgressView("搜索中...")
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else if let error = viewModel.error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(error)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("重试") {
|
||||
Task { await viewModel.search() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else if viewModel.hasSearched && viewModel.results.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("未找到「\(viewModel.query)」的相关内容")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else if !viewModel.results.isEmpty {
|
||||
ContentGridView(items: viewModel.results) {
|
||||
Task { await viewModel.loadMore() }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("输入关键词搜索影片")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("搜索")
|
||||
.navigationDestination(for: ContentItem.self) { item in
|
||||
DetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,6 +6,7 @@ let package = Package(
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14),
|
||||
.tvOS(.v17),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "LocalPackages/SwiftSoup"),
|
||||
|
||||
Reference in New Issue
Block a user