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

254 lines
7.9 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.
//
// TracyApp.swift
// Shared
//
// Created by kintan on 2021/5/3.
//
import AVFoundation
import AVKit
import KSPlayer
import SwiftUI
import UserNotifications
@main
struct TracyApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor
#else
@UIApplicationDelegateAdaptor
#endif
private var appDelegate: AppDelegate
private let appModel = APPModel()
init() {
let arguments = ProcessInfo.processInfo.arguments.dropFirst()
var dropNextArg = false
var playerArgs = [String]()
var filenames = [String]()
KSLog("launch arguments \(arguments)")
for argument in arguments {
if dropNextArg {
dropNextArg = false
continue
}
if argument.starts(with: "--") {
playerArgs.append(argument)
} else if argument.starts(with: "-") {
dropNextArg = true
} else {
filenames.append(argument)
}
}
if let urlString = filenames.first {
appModel.open(url: URL(fileURLWithPath: urlString))
}
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appModel)
.environment(\.managedObjectContext, PersistenceController.shared.viewContext)
#if !os(tvOS)
.handlesExternalEvents(preferring: Set(arrayLiteral: "pause"), allowing: Set(arrayLiteral: "*"))
#endif
}
#if !os(tvOS)
// .handlesExternalEvents(matching: Set(arrayLiteral: "*"))
.commands {
CommandGroup(before: .newItem) {
Button("Open") {
appModel.openFileImport = true
}
.keyboardShortcut("o")
}
CommandGroup(before: .newItem) {
Button("Open URL") {
appModel.openURLImport = true
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
}
#endif
#if os(macOS)
.defaultSize(width: 1120, height: 630)
.defaultPosition(.center)
#endif
#if !os(tvOS)
WindowGroup("player", for: URL.self) { $url in
if let url {
KSVideoPlayerView(url: url)
}
}
#if os(macOS)
.defaultPosition(.center)
#endif
#endif
#if !os(tvOS)
WindowGroup("player", for: MovieModel.self) { $model in
if let model {
KSVideoPlayerView(model: model)
}
}
#if os(macOS)
.defaultPosition(.center)
#endif
#endif
#if os(macOS)
Settings {
TabBarItem.Setting.destination(appModel: appModel)
}
// MenuBarExtra {
// MenuBar()
// } label: {
// Image(systemName: "film.fill")
// }
// .menuBarExtraStyle(.menu)
#endif
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
#if os(macOS)
func applicationDidFinishLaunching(_: Notification) {
// requestNotification()
}
#else
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// requestNotification()
true
}
#endif
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.reduce("") { $0 + String(format: "%02x", $1) }
print("Device push notification token - \(tokenString)")
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("Failed to register for remote notification. Error \(error)")
}
private func requestNotification() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { allowed, error in
if allowed {
// register for remote push notification
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
print("Push notification allowed by user")
} else {
print("Error while requesting push notification permission. Error \(String(describing: error))")
}
}
}
}
class APPModel: ObservableObject {
@Published
var openURL: URL?
@Published
var openPlayModel: MovieModel?
@Published
var tabSelected: TabBarItem = .Home
@Published
var path = NavigationPath()
@Published
var openFileImport = false
@Published
var openURLImport = false
@Published
var hiddenTitleBar = false
@AppStorage("activeM3UURL")
private var activeM3UURL: URL?
@Published
var activeM3UModel: M3UModel? = nil {
didSet {
if let activeM3UModel, activeM3UModel != oldValue {
activeM3UURL = activeM3UModel.m3uURL
Task { @MainActor in
_ = try? await activeM3UModel.parsePlaylist()
// m3u
if activeM3UModel == self.activeM3UModel {
self.activeM3UModel = activeM3UModel
}
}
}
}
}
init() {
#if DEBUG
// KSOptions.logLevel = .debug
#else
var fileHandle = FileHandle.standardOutput
if let logURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("log.txt") {
if !FileManager.default.fileExists(atPath: logURL.path) {
FileManager.default.createFile(atPath: logURL.path, contents: nil)
}
if let handle = try? FileHandle(forWritingTo: logURL) {
fileHandle = handle
_ = try? fileHandle.seekToEnd()
}
}
KSOptions.logger = FileLog(fileHandle: fileHandle)
#endif
// KSOptions.firstPlayerType = KSMEPlayer.self
KSOptions.secondPlayerType = KSMEPlayer.self
_ = Defaults.shared
KSOptions.subtitleDataSouces = [DirectorySubtitleDataSouce(), ShooterSubtitleDataSouce(), AssrtSubtitleDataSouce(token: "5IzWrb2J099vmA96ECQXwdRSe9xdoBUv"), OpenSubtitleDataSouce(apiKey: "0D0gt8nV6SFHVVejdxAMpvOT0wByfKE5")]
if let activeM3UURL {
addM3U(url: activeM3UURL)
}
}
func addM3U(url: URL, name: String? = nil) {
let request = M3UModel.fetchRequest()
request.predicate = NSPredicate(format: "m3uURL == %@", url.description)
let context = PersistenceController.shared.viewContext
context.perform {
self.activeM3UModel = try? context.fetch(request).first ?? M3UModel(context: context, url: url, name: name)
}
}
func open(url: URL) {
if url.isPlaylist {
addM3U(url: url)
} else {
#if os(macOS)
openURL = url
#else
path.append(url)
#endif
}
}
func content(model: MovieModel) -> some View {
#if os(macOS)
Button {
self.openPlayModel = model
} label: {
MoiveView(model: model)
}
.buttonStyle(.borderless)
#else
NavigationLink(value: model) {
MoiveView(model: model)
}
#if targetEnvironment(macCatalyst)
.buttonStyle(.borderless)
#else
.buttonStyle(.automatic)
#endif
#endif
}
}
struct KSVideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(APPModel())
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}