Files
simvision/KSPlayer-main/Sources/KSPlayer/MEPlayer/ThumbnailController.swift
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

133 lines
5.1 KiB
Swift

//
// ThumbnailController.swift
//
//
// Created by kintan on 12/27/23.
//
import AVFoundation
import Foundation
import Libavcodec
import Libavformat
#if canImport(UIKit)
import UIKit
#endif
public struct FFThumbnail {
public let image: UIImage
public let time: TimeInterval
}
public protocol ThumbnailControllerDelegate: AnyObject {
func didUpdate(thumbnails: [FFThumbnail], forFile file: URL, withProgress: Int)
}
public class ThumbnailController {
public weak var delegate: ThumbnailControllerDelegate?
private let thumbnailCount: Int
public init(thumbnailCount: Int = 100) {
self.thumbnailCount = thumbnailCount
}
public func generateThumbnail(for url: URL, thumbWidth: Int32 = 240) async throws -> [FFThumbnail] {
try await Task {
try getPeeks(for: url, thumbWidth: thumbWidth)
}.value
}
private func getPeeks(for url: URL, thumbWidth: Int32 = 240) throws -> [FFThumbnail] {
let urlString: String
if url.isFileURL {
urlString = url.path
} else {
urlString = url.absoluteString
}
var thumbnails = [FFThumbnail]()
var formatCtx = avformat_alloc_context()
defer {
avformat_close_input(&formatCtx)
}
var result = avformat_open_input(&formatCtx, urlString, nil, nil)
guard result == 0, let formatCtx else {
throw NSError(errorCode: .formatOpenInput, avErrorCode: result)
}
result = avformat_find_stream_info(formatCtx, nil)
guard result == 0 else {
throw NSError(errorCode: .formatFindStreamInfo, avErrorCode: result)
}
var videoStreamIndex = -1
for i in 0 ..< Int32(formatCtx.pointee.nb_streams) {
if formatCtx.pointee.streams[Int(i)]?.pointee.codecpar.pointee.codec_type == AVMEDIA_TYPE_VIDEO {
videoStreamIndex = Int(i)
break
}
}
guard videoStreamIndex >= 0, let videoStream = formatCtx.pointee.streams[videoStreamIndex] else {
throw NSError(description: "No video stream")
}
let videoAvgFrameRate = videoStream.pointee.avg_frame_rate
if videoAvgFrameRate.den == 0 || av_q2d(videoAvgFrameRate) == 0 {
throw NSError(description: "Avg frame rate = 0, ignore")
}
var codecContext = try videoStream.pointee.codecpar.pointee.createContext(options: nil)
defer {
avcodec_close(codecContext)
var codecContext: UnsafeMutablePointer<AVCodecContext>? = codecContext
avcodec_free_context(&codecContext)
}
let thumbHeight = thumbWidth * codecContext.pointee.height / codecContext.pointee.width
let reScale = VideoSwresample(dstWidth: thumbWidth, dstHeight: thumbHeight, isDovi: false)
// let duration = formatCtx.pointee.duration
// seekformatCtxduration
let duration = av_rescale_q(formatCtx.pointee.duration,
AVRational(num: 1, den: AV_TIME_BASE), videoStream.pointee.time_base)
let interval = duration / Int64(thumbnailCount)
var packet = AVPacket()
let timeBase = Timebase(videoStream.pointee.time_base)
var frame = av_frame_alloc()
defer {
av_frame_free(&frame)
}
guard let frame else {
throw NSError(description: "can not av_frame_alloc")
}
for i in 0 ..< thumbnailCount {
let seek_pos = interval * Int64(i) + videoStream.pointee.start_time
avcodec_flush_buffers(codecContext)
result = av_seek_frame(formatCtx, Int32(videoStreamIndex), seek_pos, AVSEEK_FLAG_BACKWARD)
guard result == 0 else {
return thumbnails
}
avcodec_flush_buffers(codecContext)
while av_read_frame(formatCtx, &packet) >= 0 {
if packet.stream_index == Int32(videoStreamIndex) {
if avcodec_send_packet(codecContext, &packet) < 0 {
break
}
let ret = avcodec_receive_frame(codecContext, frame)
if ret < 0 {
if ret == -EAGAIN {
continue
} else {
break
}
}
let image = reScale.transfer(frame: frame.pointee)?.cgImage().map {
UIImage(cgImage: $0)
}
let currentTimeStamp = frame.pointee.best_effort_timestamp
if let image {
let thumbnail = FFThumbnail(image: image, time: timeBase.cmtime(for: currentTimeStamp).seconds)
thumbnails.append(thumbnail)
delegate?.didUpdate(thumbnails: thumbnails, forFile: url, withProgress: i)
}
break
}
}
}
av_packet_unref(&packet)
reScale.shutdown()
return thumbnails
}
}