init: init proj
This commit is contained in:
98
DDYSClient/ViewModels/BrowseViewModel.swift
Normal file
98
DDYSClient/ViewModels/BrowseViewModel.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class BrowseViewModel {
|
||||
var items: [ContentItem] = []
|
||||
var category: ContentCategory = .movie
|
||||
var filter = FilterState()
|
||||
var currentPage = 1
|
||||
var totalPages = 1
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var error: String?
|
||||
|
||||
private var cacheKey: String {
|
||||
"\(category.rawValue)_\(filter.sort)_\(filter.genre)_\(filter.region)_\(filter.year)"
|
||||
}
|
||||
|
||||
var hasData: Bool {
|
||||
!items.isEmpty
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadContentIfNeeded() async {
|
||||
if hasData && !ContentCache.shared.isExpired(key: cacheKey) {
|
||||
return
|
||||
}
|
||||
await loadContent()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadContent() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
currentPage = 1
|
||||
totalPages = 1
|
||||
items = []
|
||||
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchCategoryPage(
|
||||
category: category,
|
||||
page: 1,
|
||||
filter: filter
|
||||
)
|
||||
let newItems = try HTMLParser.parseContentList(html: html, defaultCategory: category)
|
||||
let pagination = try HTMLParser.parsePagination(html: html)
|
||||
|
||||
self.items = newItems
|
||||
self.currentPage = pagination.current
|
||||
self.totalPages = pagination.total
|
||||
|
||||
ContentCache.shared.markFresh(key: cacheKey)
|
||||
} catch is CancellationError {
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadMore() async {
|
||||
guard !isLoadingMore, !isLoading, currentPage < totalPages else { return }
|
||||
isLoadingMore = true
|
||||
|
||||
let nextPage = currentPage + 1
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchCategoryPage(
|
||||
category: category,
|
||||
page: nextPage,
|
||||
filter: filter
|
||||
)
|
||||
let newItems = try HTMLParser.parseContentList(html: html, defaultCategory: category)
|
||||
let pagination = try HTMLParser.parsePagination(html: html)
|
||||
|
||||
self.items.append(contentsOf: newItems)
|
||||
self.currentPage = pagination.current
|
||||
self.totalPages = pagination.total
|
||||
} catch is CancellationError {
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
} catch {
|
||||
// 加载更多失败不显示全局错误,允许重试
|
||||
}
|
||||
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
func changeCategory(_ newCategory: ContentCategory) async {
|
||||
category = newCategory
|
||||
filter = FilterState()
|
||||
await loadContentIfNeeded()
|
||||
}
|
||||
|
||||
func applyFilter(_ newFilter: FilterState) async {
|
||||
filter = newFilter
|
||||
await loadContent()
|
||||
}
|
||||
}
|
||||
66
DDYSClient/ViewModels/DetailViewModel.swift
Normal file
66
DDYSClient/ViewModels/DetailViewModel.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class DetailViewModel {
|
||||
var detail: ContentDetail?
|
||||
var selectedSourceIndex = 0
|
||||
var selectedEpisodeIndex = 0
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
var currentSource: StreamSource? {
|
||||
guard let detail, selectedSourceIndex < detail.sources.count else { return nil }
|
||||
return detail.sources[selectedSourceIndex]
|
||||
}
|
||||
|
||||
var currentEpisode: Episode? {
|
||||
guard let source = currentSource,
|
||||
selectedEpisodeIndex < source.episodes.count else { return nil }
|
||||
return source.episodes[selectedEpisodeIndex]
|
||||
}
|
||||
|
||||
var hasMultipleEpisodes: Bool {
|
||||
guard let source = currentSource else { return false }
|
||||
return source.episodes.count > 1
|
||||
}
|
||||
|
||||
func loadDetail(path: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchDetailPage(path: path)
|
||||
let parsedDetail = try HTMLParser.parseContentDetail(html: html)
|
||||
await MainActor.run {
|
||||
self.detail = parsedDetail
|
||||
self.selectedSourceIndex = 0
|
||||
self.selectedEpisodeIndex = 0
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
return
|
||||
} catch {
|
||||
await MainActor.run { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
func selectSource(_ index: Int) {
|
||||
selectedSourceIndex = index
|
||||
selectedEpisodeIndex = 0
|
||||
}
|
||||
|
||||
func selectEpisode(_ index: Int) {
|
||||
selectedEpisodeIndex = index
|
||||
}
|
||||
|
||||
func nextEpisode() -> Bool {
|
||||
guard let source = currentSource else { return false }
|
||||
if selectedEpisodeIndex + 1 < source.episodes.count {
|
||||
selectedEpisodeIndex += 1
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
65
DDYSClient/ViewModels/HomeViewModel.swift
Normal file
65
DDYSClient/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
var recommendedItems: [ContentItem] = []
|
||||
var latestItems: [ContentItem] = []
|
||||
var hotMovies: [HotMovieItem] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
private let cacheKey = "home"
|
||||
|
||||
var hasData: Bool {
|
||||
!recommendedItems.isEmpty || !latestItems.isEmpty
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadHomeIfNeeded() async {
|
||||
if hasData && !ContentCache.shared.isExpired(key: cacheKey) {
|
||||
return
|
||||
}
|
||||
await loadHome()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadHome() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadHomePage() }
|
||||
group.addTask { await self.loadHotMovies() }
|
||||
}
|
||||
|
||||
if error == nil && hasData {
|
||||
ContentCache.shared.markFresh(key: cacheKey)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadHomePage() async {
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchHomePage()
|
||||
let sections = try HTMLParser.parseHomeSections(html: html)
|
||||
await MainActor.run {
|
||||
if sections.count > 0 { self.recommendedItems = sections[0] }
|
||||
if sections.count > 1 { self.latestItems = sections[1] }
|
||||
}
|
||||
} catch is CancellationError {
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
} catch {
|
||||
await MainActor.run { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHotMovies() async {
|
||||
do {
|
||||
let movies = try await APIClient.shared.fetchHotMovies()
|
||||
await MainActor.run { self.hotMovies = movies }
|
||||
} catch {
|
||||
// 热门 API 失败不影响主页显示
|
||||
}
|
||||
}
|
||||
}
|
||||
134
DDYSClient/ViewModels/PlayerViewModel.swift
Normal file
134
DDYSClient/ViewModels/PlayerViewModel.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class PlayerViewModel {
|
||||
var player: AVPlayer?
|
||||
var isPlaying = false
|
||||
var currentTime: Double = 0
|
||||
var duration: Double = 0
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var playbackRate: Float = 1.0
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var contentId: String = ""
|
||||
private var episodeId: Int = 0
|
||||
|
||||
var progress: Double {
|
||||
guard duration > 0 else { return 0 }
|
||||
return currentTime / duration
|
||||
}
|
||||
|
||||
var currentTimeText: String {
|
||||
formatTime(currentTime)
|
||||
}
|
||||
|
||||
var durationText: String {
|
||||
formatTime(duration)
|
||||
}
|
||||
|
||||
func play(url: String, contentId: String, episodeId: Int = 0) {
|
||||
self.contentId = contentId
|
||||
self.episodeId = episodeId
|
||||
|
||||
guard let videoURL = URL(string: url) else {
|
||||
error = "无效的视频地址"
|
||||
return
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(url: videoURL)
|
||||
player = AVPlayer(playerItem: playerItem)
|
||||
player?.rate = playbackRate
|
||||
|
||||
// 恢复上次进度
|
||||
if let savedProgress = WatchProgressStore.shared.getProgress(for: contentId, episodeId: episodeId) {
|
||||
let resumeTime = CMTime(seconds: savedProgress.currentTime, preferredTimescale: 600)
|
||||
player?.seek(to: resumeTime)
|
||||
}
|
||||
|
||||
setupTimeObserver()
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
guard let player else { return }
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
|
||||
player?.seek(to: cmTime)
|
||||
}
|
||||
|
||||
func seekForward(_ seconds: Double = 10) {
|
||||
seek(to: min(currentTime + seconds, duration))
|
||||
}
|
||||
|
||||
func seekBackward(_ seconds: Double = 10) {
|
||||
seek(to: max(currentTime - seconds, 0))
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
playbackRate = rate
|
||||
player?.rate = rate
|
||||
}
|
||||
|
||||
func stop() {
|
||||
saveProgress()
|
||||
player?.pause()
|
||||
if let observer = timeObserver {
|
||||
player?.removeTimeObserver(observer)
|
||||
}
|
||||
timeObserver = nil
|
||||
player = nil
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
private func setupTimeObserver() {
|
||||
let interval = CMTime(seconds: 1, preferredTimescale: 600)
|
||||
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
guard let self else { return }
|
||||
self.currentTime = time.seconds
|
||||
if let item = self.player?.currentItem {
|
||||
self.duration = item.duration.seconds.isNaN ? 0 : item.duration.seconds
|
||||
}
|
||||
// 每 10 秒保存一次进度
|
||||
if Int(self.currentTime) % 10 == 0 {
|
||||
self.saveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
guard duration > 0 else { return }
|
||||
WatchProgressStore.shared.saveProgress(
|
||||
contentId: contentId,
|
||||
episodeId: episodeId,
|
||||
currentTime: currentTime,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
guard !seconds.isNaN, seconds >= 0 else { return "00:00" }
|
||||
let totalSeconds = Int(seconds)
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let secs = totalSeconds % 60
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, secs)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
75
DDYSClient/ViewModels/SearchViewModel.swift
Normal file
75
DDYSClient/ViewModels/SearchViewModel.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class SearchViewModel {
|
||||
var query = ""
|
||||
var results: [ContentItem] = []
|
||||
var isSearching = false
|
||||
var isLoadingMore = false
|
||||
var currentPage = 1
|
||||
var totalPages = 1
|
||||
var error: String?
|
||||
var hasSearched = false
|
||||
|
||||
@MainActor
|
||||
func search() async {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
isSearching = true
|
||||
error = nil
|
||||
hasSearched = true
|
||||
currentPage = 1
|
||||
totalPages = 1
|
||||
results = []
|
||||
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchSearchPage(query: trimmed)
|
||||
let items = try HTMLParser.parseContentList(html: html)
|
||||
let pagination = try HTMLParser.parsePagination(html: html)
|
||||
|
||||
self.results = items
|
||||
self.currentPage = pagination.current
|
||||
self.totalPages = pagination.total
|
||||
} catch is CancellationError {
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isSearching = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadMore() async {
|
||||
guard !isSearching, !isLoadingMore, currentPage < totalPages else { return }
|
||||
isLoadingMore = true
|
||||
|
||||
let nextPage = currentPage + 1
|
||||
do {
|
||||
let html = try await APIClient.shared.fetchSearchPage(query: query, page: nextPage)
|
||||
let items = try HTMLParser.parseContentList(html: html)
|
||||
let pagination = try HTMLParser.parsePagination(html: html)
|
||||
|
||||
self.results.append(contentsOf: items)
|
||||
self.currentPage = pagination.current
|
||||
self.totalPages = pagination.total
|
||||
} catch is CancellationError {
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
} catch {
|
||||
// 静默处理
|
||||
}
|
||||
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
hasSearched = false
|
||||
error = nil
|
||||
currentPage = 1
|
||||
totalPages = 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user