Files
Michael Simard 872354b834 Initial commit: SimVision tvOS streaming app
Features:
- VOD library with movie grouping and version detection
- TV show library with season/episode organization
- TMDB integration for trending shows and recently aired episodes
- Recent releases section with TMDB release date sorting
- Watch history tracking with continue watching
- Playlist caching (12-hour TTL) for offline support
- M3U playlist parsing with XStream API support
- Authentication with credential storage

Technical:
- SwiftUI for tvOS
- Actor-based services for thread safety
- Persistent caching for playlists, TMDB data, and watch history
- KSPlayer integration for video playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:12:08 -06:00

186 lines
6.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import KSPlayer
import SwiftUI
struct HomeView: View {
@EnvironmentObject
private var appModel: APPModel
@State
private var nameFilter: String = ""
@State
private var groupFilter: String?
@Default(\.showRecentPlayList)
private var showRecentPlayList
// @Environment(\.horizontalSizeClass) var horizontalSizeClass
@FetchRequest(fetchRequest: MovieModel.playTimeRequest)
private var historyModels: FetchedResults<MovieModel>
@FetchRequest
private var movieModels: FetchedResults<MovieModel>
init(m3uURL: URL?) {
let request = MovieModel.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \MovieModel.name, ascending: true)]
request.predicate = NSPredicate(format: "m3uURL == %@ && name != nil ", m3uURL?.description ?? "nil")
_movieModels = FetchRequest<MovieModel>(fetchRequest: request)
}
var body: some View {
ScrollView {
#if os(tvOS)
HStack {
toolbarView
}
#endif
if showRecentPlayList {
Section {
ScrollView(.horizontal) {
LazyHStack {
ForEach(historyModels) { model in
appModel.content(model: model)
}
}
}
} header: {
HStack {
Text("Recent Play").font(.title)
Spacer()
}
.padding(.horizontal)
}
.padding()
}
Section {
LazyVGrid(columns: [GridItem(.adaptive(minimum: MoiveView.width))]) {
let playlist = movieModels.filter { model in
var isIncluded = true
if !nameFilter.isEmpty {
isIncluded = model.name!.contains(nameFilter)
}
if let groupFilter {
isIncluded = isIncluded && model.group == groupFilter
}
return isIncluded
}
ForEach(playlist) { model in
appModel.content(model: model)
}
}
} header: {
HStack {
Text("Channels").font(.title)
Spacer()
}
.padding(.horizontal)
}
.padding()
}
#if !os(tvOS)
// tvossearchable
.searchable(text: $nameFilter)
.toolbar {
toolbarView
}
#endif
}
private var toolbarView: some View {
Group {
Button {
appModel.openFileImport = true
} label: {
Label("Open File", systemImage: "plus.rectangle.on.folder.fill")
}
#if !os(tvOS)
.keyboardShortcut("o")
#endif
Button {
appModel.openURLImport = true
} label: {
Label("Open URL", systemImage: "plus.app.fill")
}
#if !os(tvOS)
.keyboardShortcut("o", modifiers: [.command, .shift])
#endif
let groups = movieModels.reduce(Set<String>()) { partialResult, model in
if let group = model.group {
var set = partialResult
set.insert(group)
return set
} else {
return partialResult
}
}.sorted()
Picker("group filter", selection: $groupFilter) {
Text("All").tag(nil as String?)
ForEach(groups) { group in
Text(group).tag(group as String?)
}
}
#if os(tvOS)
.pickerStyle(.navigationLink)
#endif
}
.labelStyle(.titleAndIcon)
}
}
struct MoiveView: View {
static let width: CGFloat = {
#if canImport(UIKit)
if UIDevice.current.userInterfaceIdiom == .phone {
return min(KSOptions.sceneSize.width, KSOptions.sceneSize.height) / 2 - 20
} else if UIDevice.current.userInterfaceIdiom == .pad {
return min(KSOptions.sceneSize.width, KSOptions.sceneSize.height) / 3 - 20
} else if UIDevice.current.userInterfaceIdiom == .tv {
return KSOptions.sceneSize.width / 4 - 150
} else if UIDevice.current.userInterfaceIdiom == .mac {
return CGFloat(192)
} else {
return CGFloat(192)
}
#else
return CGFloat(192)
#endif
}()
@ObservedObject var model: MovieModel
var body: some View {
VStack {
image
Text(model.name ?? "").lineLimit(1)
}
.frame(width: MoiveView.width)
.contextMenu {
Button {
model.isFavorite.toggle()
try? model.managedObjectContext?.save()
} label: {
let isFavorite = model.isFavorite
Label(isFavorite ? "Cancel favorite" : "Favorite", systemImage: isFavorite ? "star" : "star.fill")
}
#if !os(tvOS)
Button {
#if os(macOS)
UIPasteboard.general.clearContents()
UIPasteboard.general.setString(model.url!.description, forType: .string)
#else
UIPasteboard.general.setValue(model.url!, forPasteboardType: "public.url")
#endif
} label: {
Label("Copy url", systemImage: "doc.on.doc.fill")
}
#endif
}
}
var image: some View {
AsyncImage(url: model.logo) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.gray
}.frame(width: MoiveView.width, height: MoiveView.width / 16 * 9)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
}