init: init proj

This commit is contained in:
2026-02-26 22:15:35 +08:00
commit 7ef5348f65
43 changed files with 3085 additions and 0 deletions

View 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()
}
}

View 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
}
}

View 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
}
}
}

View 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()
}
}

View 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
}
}