Compare commits
4 Commits
main
...
2e5348cc97
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e5348cc97 | |||
| bcfee96fa9 | |||
| 1da412e637 | |||
| cd8c3afd7d |
@@ -20,7 +20,12 @@
|
|||||||
"Bash(xargs -I{} sample {} 1)",
|
"Bash(xargs -I{} sample {} 1)",
|
||||||
"Bash(killall xcodebuild:*)",
|
"Bash(killall xcodebuild:*)",
|
||||||
"Bash(cp:*)",
|
"Bash(cp:*)",
|
||||||
"Bash(pkill:*)"
|
"Bash(pkill:*)",
|
||||||
|
"Bash(git:*)",
|
||||||
|
"Bash(xcrun simctl:*)",
|
||||||
|
"Bash(xcrun devicectl:*)",
|
||||||
|
"Bash(networksetup:*)",
|
||||||
|
"Bash(system_profiler SPNetworkDataType:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ enum HTMLParser {
|
|||||||
let h1 = try doc.select("h1").first()
|
let h1 = try doc.select("h1").first()
|
||||||
let fullTitle = try h1?.text().trimmingCharacters(in: .whitespacesAndNewlines) ?? "未知标题"
|
let fullTitle = try h1?.text().trimmingCharacters(in: .whitespacesAndNewlines) ?? "未知标题"
|
||||||
|
|
||||||
// 海报
|
|
||||||
let imgSrc = try doc.select("img.w-full.h-full.object-cover").first()?.attr("src") ?? ""
|
|
||||||
let posterURL = URL(string: imgSrc)
|
|
||||||
|
|
||||||
// === 优先从 JSON-LD 提取结构化数据 ===
|
// === 优先从 JSON-LD 提取结构化数据 ===
|
||||||
var year = 0
|
var year = 0
|
||||||
var rating: Double?
|
var rating: Double?
|
||||||
@@ -105,12 +101,17 @@ enum HTMLParser {
|
|||||||
var genres: [String] = []
|
var genres: [String] = []
|
||||||
var description = ""
|
var description = ""
|
||||||
var region = ""
|
var region = ""
|
||||||
|
var posterURL: URL?
|
||||||
|
|
||||||
if let jsonLDScript = try doc.select("script[type=application/ld+json]").first() {
|
if let jsonLDScript = try doc.select("script[type=application/ld+json]").first() {
|
||||||
let jsonText = try jsonLDScript.data()
|
let jsonText = try jsonLDScript.data()
|
||||||
if let data = jsonText.data(using: .utf8),
|
if let data = jsonText.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
|
||||||
|
if let imageStr = json["image"] as? String {
|
||||||
|
posterURL = URL(string: imageStr)
|
||||||
|
}
|
||||||
|
|
||||||
if let published = json["datePublished"] as? String {
|
if let published = json["datePublished"] as? String {
|
||||||
year = Int(published) ?? 0
|
year = Int(published) ?? 0
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,12 @@ enum HTMLParser {
|
|||||||
|
|
||||||
// === 从 HTML 补充缺失数据 ===
|
// === 从 HTML 补充缺失数据 ===
|
||||||
|
|
||||||
|
// 海报 fallback: 主海报在 .flex-shrink-0 容器中
|
||||||
|
if posterURL == nil {
|
||||||
|
let imgSrc = try doc.select(".flex-shrink-0 img.object-cover").first()?.attr("src") ?? ""
|
||||||
|
posterURL = URL(string: imgSrc)
|
||||||
|
}
|
||||||
|
|
||||||
// 评分 fallback
|
// 评分 fallback
|
||||||
if rating == nil {
|
if rating == nil {
|
||||||
let ratingText = try doc.select(".rating-display").text()
|
let ratingText = try doc.select(".rating-display").text()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@Observable @MainActor
|
||||||
final class DetailViewModel {
|
final class DetailViewModel {
|
||||||
var detail: ContentDetail?
|
var detail: ContentDetail?
|
||||||
var selectedSourceIndex = 0
|
var selectedSourceIndex = 0
|
||||||
@@ -27,23 +27,22 @@ final class DetailViewModel {
|
|||||||
func loadDetail(path: String) async {
|
func loadDetail(path: String) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
defer { isLoading = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let html = try await APIClient.shared.fetchDetailPage(path: path)
|
let html = try await APIClient.shared.fetchDetailPage(path: path)
|
||||||
let parsedDetail = try HTMLParser.parseContentDetail(html: html)
|
let parsedDetail = try HTMLParser.parseContentDetail(html: html)
|
||||||
await MainActor.run {
|
self.detail = parsedDetail
|
||||||
self.detail = parsedDetail
|
self.selectedSourceIndex = 0
|
||||||
self.selectedSourceIndex = 0
|
self.selectedEpisodeIndex = 0
|
||||||
self.selectedEpisodeIndex = 0
|
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
|
// 被取消时不清除 loading 状态,让视图继续显示加载中
|
||||||
return
|
return
|
||||||
} catch let error as URLError where error.code == .cancelled {
|
} catch let error as URLError where error.code == .cancelled {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run { self.error = error.localizedDescription }
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectSource(_ index: Int) {
|
func selectSource(_ index: Int) {
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ struct CookieInputView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Cookie") {
|
Section("Cookie") {
|
||||||
|
#if os(tvOS)
|
||||||
|
TextField("粘贴 Cookie...", text: $cookieText)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
#else
|
||||||
TextEditor(text: $cookieText)
|
TextEditor(text: $cookieText)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.frame(minHeight: 120)
|
.frame(minHeight: 120)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ struct BrowseView: View {
|
|||||||
.navigationDestination(for: ContentItem.self) { item in
|
.navigationDestination(for: ContentItem.self) { item in
|
||||||
DetailView(item: item)
|
DetailView(item: item)
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.loadContent()
|
await viewModel.loadContent()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.task {
|
.task {
|
||||||
viewModel.category = category
|
viewModel.category = category
|
||||||
await viewModel.loadContentIfNeeded()
|
await viewModel.loadContentIfNeeded()
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ import SwiftUI
|
|||||||
struct ContentCardView: View {
|
struct ContentCardView: View {
|
||||||
let item: ContentItem
|
let item: ContentItem
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private let cardWidth: CGFloat = 250
|
||||||
|
#else
|
||||||
private let cardWidth: CGFloat = 140
|
private let cardWidth: CGFloat = 140
|
||||||
|
#endif
|
||||||
private let aspectRatio: CGFloat = 2.0 / 3.0
|
private let aspectRatio: CGFloat = 2.0 / 3.0
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
@Environment(\.isFocused) private var isFocused
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// 海报
|
// 海报
|
||||||
@@ -54,6 +62,11 @@ struct ContentCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: cardWidth)
|
.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
|
@ViewBuilder
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ struct ContentGridView: View {
|
|||||||
let items: [ContentItem]
|
let items: [ContentItem]
|
||||||
var onNearEnd: (() -> Void)?
|
var onNearEnd: (() -> Void)?
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 24)
|
||||||
|
]
|
||||||
|
#else
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
|
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
|
||||||
]
|
]
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
|||||||
@@ -7,15 +7,47 @@ struct FilterBarView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 16) {
|
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.defaultSorts, selection: $filter.sort)
|
||||||
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
|
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
|
||||||
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
|
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
|
||||||
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
|
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.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 {
|
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(options) { option in
|
ForEach(options) { option in
|
||||||
@@ -52,4 +84,5 @@ struct FilterBarView: View {
|
|||||||
guard !value.isEmpty else { return nil }
|
guard !value.isEmpty else { return nil }
|
||||||
return options.first { $0.value == value }?.name
|
return options.first { $0.value == value }?.name
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,29 +10,45 @@ struct DetailView: View {
|
|||||||
@State private var showPlayer = false
|
@State private var showPlayer = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoading {
|
if let detail = viewModel.detail {
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if let error = viewModel.error {
|
|
||||||
errorView(error)
|
|
||||||
} else if let detail = viewModel.detail {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
detailContent(detail)
|
detailContent(detail)
|
||||||
}
|
}
|
||||||
|
} else if let error = viewModel.error {
|
||||||
|
errorView(error)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(item.title)
|
.navigationTitle(item.title)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.task {
|
.task(id: item.id) {
|
||||||
if viewModel.detail == nil {
|
if viewModel.detail == nil {
|
||||||
await viewModel.loadDetail(path: item.detailURL)
|
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) {
|
.sheet(isPresented: $showPlayer) {
|
||||||
if let episode = viewModel.currentEpisode {
|
if let episode = viewModel.currentEpisode {
|
||||||
VideoPlayerView(
|
VideoPlayerView(
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ struct EpisodeListView: View {
|
|||||||
let selectedIndex: Int
|
let selectedIndex: Int
|
||||||
let onSelect: (Int) -> Void
|
let onSelect: (Int) -> Void
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||||
|
]
|
||||||
|
#else
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8)
|
GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8)
|
||||||
]
|
]
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct HomeView: View {
|
|||||||
.navigationDestination(for: ContentItem.self) { item in
|
.navigationDestination(for: ContentItem.self) { item in
|
||||||
DetailView(item: item)
|
DetailView(item: item)
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
.searchable(text: $searchText, prompt: "搜索电影、电视剧...")
|
.searchable(text: $searchText, prompt: "搜索电影、电视剧...")
|
||||||
.onSubmit(of: .search) {
|
.onSubmit(of: .search) {
|
||||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -52,6 +53,7 @@ struct HomeView: View {
|
|||||||
await viewModel.loadHome()
|
await viewModel.loadHome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadHomeIfNeeded()
|
await viewModel.loadHomeIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum AppTab: String, CaseIterable, Identifiable {
|
enum AppTab: String, Identifiable {
|
||||||
case home
|
case home
|
||||||
case movie
|
case movie
|
||||||
case series
|
case series
|
||||||
case variety
|
case variety
|
||||||
case anime
|
case anime
|
||||||
|
case search
|
||||||
case settings
|
case settings
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
@@ -17,6 +18,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
|||||||
case .series: return "电视剧"
|
case .series: return "电视剧"
|
||||||
case .variety: return "综艺"
|
case .variety: return "综艺"
|
||||||
case .anime: return "动漫"
|
case .anime: return "动漫"
|
||||||
|
case .search: return "搜索"
|
||||||
case .settings: return "设置"
|
case .settings: return "设置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
|||||||
case .series: return "tv"
|
case .series: return "tv"
|
||||||
case .variety: return "theatermasks"
|
case .variety: return "theatermasks"
|
||||||
case .anime: return "sparkles"
|
case .anime: return "sparkles"
|
||||||
|
case .search: return "magnifyingglass"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,14 @@ enum AppTab: String, CaseIterable, Identifiable {
|
|||||||
default: return nil
|
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 {
|
struct AppNavigation: View {
|
||||||
@@ -55,6 +66,8 @@ struct AppNavigation: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
sidebarLayout
|
sidebarLayout
|
||||||
|
#elseif os(tvOS)
|
||||||
|
tabLayout
|
||||||
#elseif os(visionOS)
|
#elseif os(visionOS)
|
||||||
sidebarLayout
|
sidebarLayout
|
||||||
#else
|
#else
|
||||||
@@ -68,7 +81,7 @@ struct AppNavigation: View {
|
|||||||
|
|
||||||
private var tabLayout: some View {
|
private var tabLayout: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
ForEach(AppTab.allCases) { tab in
|
ForEach(AppTab.visibleTabs) { tab in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
tabContent(for: tab)
|
tabContent(for: tab)
|
||||||
}
|
}
|
||||||
@@ -80,9 +93,10 @@ struct AppNavigation: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
private var sidebarLayout: some View {
|
private var sidebarLayout: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(AppTab.allCases, selection: $selectedTab) { tab in
|
List(AppTab.visibleTabs, selection: $selectedTab) { tab in
|
||||||
Label(tab.title, systemImage: tab.icon)
|
Label(tab.title, systemImage: tab.icon)
|
||||||
.tag(tab)
|
.tag(tab)
|
||||||
}
|
}
|
||||||
@@ -93,6 +107,7 @@ struct AppNavigation: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tabContent(for tab: AppTab) -> some View {
|
private func tabContent(for tab: AppTab) -> some View {
|
||||||
@@ -107,6 +122,12 @@ struct AppNavigation: View {
|
|||||||
BrowseView(category: .variety, viewModel: varietyVM)
|
BrowseView(category: .variety, viewModel: varietyVM)
|
||||||
case .anime:
|
case .anime:
|
||||||
BrowseView(category: .anime, viewModel: animeVM)
|
BrowseView(category: .anime, viewModel: animeVM)
|
||||||
|
case .search:
|
||||||
|
#if os(tvOS)
|
||||||
|
TVSearchView()
|
||||||
|
#else
|
||||||
|
EmptyView()
|
||||||
|
#endif
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ struct NativePlayerView: UIViewControllerRepresentable {
|
|||||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
let vc = AVPlayerViewController()
|
let vc = AVPlayerViewController()
|
||||||
vc.player = player
|
vc.player = player
|
||||||
|
#if !os(tvOS)
|
||||||
vc.allowsPictureInPicturePlayback = true
|
vc.allowsPictureInPicturePlayback = true
|
||||||
|
#endif
|
||||||
return vc
|
return vc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +54,43 @@ struct VideoPlayerView: View {
|
|||||||
|
|
||||||
@State private var viewModel = PlayerViewModel()
|
@State private var viewModel = PlayerViewModel()
|
||||||
|
|
||||||
#if os(iOS)
|
#if !os(macOS)
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
macOSPlayer
|
macOSPlayer
|
||||||
|
#elseif os(tvOS)
|
||||||
|
tvOSPlayer
|
||||||
#else
|
#else
|
||||||
iOSPlayer
|
iOSPlayer
|
||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
private var iOSPlayer: some View {
|
private var iOSPlayer: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@State private var viewModel = SearchViewModel()
|
@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
|
||||||
Submodule LocalPackages/SwiftSoup updated: 8b6cf29eea...e98a6d63ce
23
Package.resolved
Normal file
23
Package.resolved
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "lrucache",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/nicklockwood/LRUCache.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ let package = Package(
|
|||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v17),
|
||||||
.macOS(.v14),
|
.macOS(.v14),
|
||||||
|
.tvOS(.v17),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "LocalPackages/SwiftSoup"),
|
.package(path: "LocalPackages/SwiftSoup"),
|
||||||
|
|||||||
27
project.yml
27
project.yml
@@ -4,6 +4,7 @@ options:
|
|||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "17.0"
|
iOS: "17.0"
|
||||||
macOS: "14.0"
|
macOS: "14.0"
|
||||||
|
tvOS: "17.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
groupSortPosition: top
|
groupSortPosition: top
|
||||||
createIntermediateGroups: true
|
createIntermediateGroups: true
|
||||||
@@ -32,6 +33,26 @@ targets:
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName: DDYS
|
INFOPLIST_KEY_CFBundleDisplayName: DDYS
|
||||||
CODE_SIGN_ENTITLEMENTS: DDYSClient/DDYSClient.entitlements
|
CODE_SIGN_ENTITLEMENTS: DDYSClient/DDYSClient.entitlements
|
||||||
|
|
||||||
|
DDYSClient-tvOS:
|
||||||
|
type: application
|
||||||
|
platform: tvOS
|
||||||
|
sources:
|
||||||
|
- path: DDYSClient
|
||||||
|
resources:
|
||||||
|
- path: Resources
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
- package: SwiftSoup
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.fusion.ddys.client
|
||||||
|
MARKETING_VERSION: "1.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: 低端影视
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
DDYSClient:
|
DDYSClient:
|
||||||
build:
|
build:
|
||||||
@@ -39,3 +60,9 @@ schemes:
|
|||||||
DDYSClient: all
|
DDYSClient: all
|
||||||
run:
|
run:
|
||||||
config: Debug
|
config: Debug
|
||||||
|
DDYSClient-tvOS:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
DDYSClient-tvOS: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
|||||||
Reference in New Issue
Block a user