From cd8c3afd7d8d107ee901d82d125a2ac8904398e1 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 26 Feb 2026 22:45:05 +0800 Subject: [PATCH] feat: for tvOS --- .claude/settings.local.json | 3 +- DDYSClient/Views/Browse/BrowseView.swift | 2 + DDYSClient/Views/Browse/ContentCardView.swift | 13 ++++ DDYSClient/Views/Browse/ContentGridView.swift | 6 ++ DDYSClient/Views/Browse/FilterBarView.swift | 33 +++++++++ DDYSClient/Views/Detail/DetailView.swift | 18 ++++- DDYSClient/Views/Detail/EpisodeListView.swift | 6 ++ DDYSClient/Views/Home/HomeView.swift | 2 + .../Views/Navigation/AppNavigation.swift | 25 ++++++- DDYSClient/Views/Player/VideoPlayerView.swift | 29 +++++++- DDYSClient/Views/Search/SearchView.swift | 2 + DDYSClient/Views/Search/TVSearchView.swift | 74 +++++++++++++++++++ Package.swift | 1 + 13 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 DDYSClient/Views/Search/TVSearchView.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d19ab41..cf44416 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,8 @@ "Bash(xargs -I{} sample {} 1)", "Bash(killall xcodebuild:*)", "Bash(cp:*)", - "Bash(pkill:*)" + "Bash(pkill:*)", + "Bash(git:*)" ] } } diff --git a/DDYSClient/Views/Browse/BrowseView.swift b/DDYSClient/Views/Browse/BrowseView.swift index b5cb7a8..4f01815 100644 --- a/DDYSClient/Views/Browse/BrowseView.swift +++ b/DDYSClient/Views/Browse/BrowseView.swift @@ -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() diff --git a/DDYSClient/Views/Browse/ContentCardView.swift b/DDYSClient/Views/Browse/ContentCardView.swift index 7c4e498..d70a1bf 100644 --- a/DDYSClient/Views/Browse/ContentCardView.swift +++ b/DDYSClient/Views/Browse/ContentCardView.swift @@ -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 diff --git a/DDYSClient/Views/Browse/ContentGridView.swift b/DDYSClient/Views/Browse/ContentGridView.swift index 255c106..467137c 100644 --- a/DDYSClient/Views/Browse/ContentGridView.swift +++ b/DDYSClient/Views/Browse/ContentGridView.swift @@ -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) { diff --git a/DDYSClient/Views/Browse/FilterBarView.swift b/DDYSClient/Views/Browse/FilterBarView.swift index 94b6c47..1d6a58a 100644 --- a/DDYSClient/Views/Browse/FilterBarView.swift +++ b/DDYSClient/Views/Browse/FilterBarView.swift @@ -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) -> 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) -> 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 } diff --git a/DDYSClient/Views/Detail/DetailView.swift b/DDYSClient/Views/Detail/DetailView.swift index aa48d04..8dc59fd 100644 --- a/DDYSClient/Views/Detail/DetailView.swift +++ b/DDYSClient/Views/Detail/DetailView.swift @@ -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( diff --git a/DDYSClient/Views/Detail/EpisodeListView.swift b/DDYSClient/Views/Detail/EpisodeListView.swift index 1cfb3e7..c0a4109 100644 --- a/DDYSClient/Views/Detail/EpisodeListView.swift +++ b/DDYSClient/Views/Detail/EpisodeListView.swift @@ -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) { diff --git a/DDYSClient/Views/Home/HomeView.swift b/DDYSClient/Views/Home/HomeView.swift index cfad7dc..0ba568f 100644 --- a/DDYSClient/Views/Home/HomeView.swift +++ b/DDYSClient/Views/Home/HomeView.swift @@ -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() } diff --git a/DDYSClient/Views/Navigation/AppNavigation.swift b/DDYSClient/Views/Navigation/AppNavigation.swift index 01b391e..177df4f 100644 --- a/DDYSClient/Views/Navigation/AppNavigation.swift +++ b/DDYSClient/Views/Navigation/AppNavigation.swift @@ -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() } diff --git a/DDYSClient/Views/Player/VideoPlayerView.swift b/DDYSClient/Views/Player/VideoPlayerView.swift index 8960666..6a95105 100644 --- a/DDYSClient/Views/Player/VideoPlayerView.swift +++ b/DDYSClient/Views/Player/VideoPlayerView.swift @@ -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 { diff --git a/DDYSClient/Views/Search/SearchView.swift b/DDYSClient/Views/Search/SearchView.swift index 2f12cab..641a1b9 100644 --- a/DDYSClient/Views/Search/SearchView.swift +++ b/DDYSClient/Views/Search/SearchView.swift @@ -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 diff --git a/DDYSClient/Views/Search/TVSearchView.swift b/DDYSClient/Views/Search/TVSearchView.swift new file mode 100644 index 0000000..74d75b9 --- /dev/null +++ b/DDYSClient/Views/Search/TVSearchView.swift @@ -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 diff --git a/Package.swift b/Package.swift index 6963fb1..5903be3 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( platforms: [ .iOS(.v17), .macOS(.v14), + .tvOS(.v17), ], dependencies: [ .package(path: "LocalPackages/SwiftSoup"),