首次提交

This commit is contained in:
zeng 2025-10-16 11:39:38 +08:00
parent 1ff6f4fd4f
commit d1a1bde3aa
294 changed files with 11637 additions and 2 deletions

4
.gitignore vendored
View File

@ -38,10 +38,10 @@ playground.xcworkspace
# you should judge for yourself, the pros and cons are mentioned at: # you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
# #
# Pods/ Pods/
# #
# Add this line if you want to avoid checking in source code from the Xcode workspace # Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace *.xcworkspace
# Carthage # Carthage
# #

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
//
// AppDelegate+FAConfig.swift
// Fableon
//
// Created by 鸿 on 2025/9/26.
//
import UIKit
import MJRefresh
import IQKeyboardManagerSwift
import IQKeyboardToolbarManager
extension AppDelegate {
func fa_config() {
UIView.fa_Awake()
FAToast.config()
//
MJRefreshConfig.default.languageCode = "en"
IQKeyboardManager.shared.isEnabled = true
IQKeyboardManager.shared.resignOnTouchOutside = true
IQKeyboardToolbarManager.shared.isEnabled = false
let appearance = UINavigationBarAppearance.defaultAppearance()
UINavigationBar.appearance().scrollEdgeAppearance = appearance
UINavigationBar.appearance().standardAppearance = appearance
}
}

View File

@ -0,0 +1,52 @@
//
// AppDelegate.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FANetworkMonitor.manager.startMonitoring()
self.fa_config()
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: FANetworkMonitor.networkStatusDidChangeNotification, object: nil)
DispatchQueue.main.asyncAfter(wallDeadline: .now() + 0.2) {
if FANetworkMonitor.manager.isReachable == true {
FALogin.manager.requestUserInfo(completer: nil)
}
}
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
@objc private func networkStatusDidChangeNotification() {
if FANetworkMonitor.manager.isReachable == true {
FALogin.manager.requestUserInfo(completer: nil)
}
}
}

View File

@ -0,0 +1,54 @@
//
// SceneDelegate.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
FATool.windowScene = windowScene
window = UIWindow(windowScene: windowScene)
window?.rootViewController = FATabBarController()
window?.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}

View File

@ -0,0 +1,43 @@
//
// FANavigationController.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
import FDFullscreenPopGesture
class FANavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
fd_fullscreenPopGestureRecognizer.isEnabled = true
}
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
if children.count > 0 {
viewController.hidesBottomBarWhenPushed = true
}
super.pushViewController(viewController, animated: animated)
}
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
for (index, value) in viewControllers.enumerated() {
if index != 0 {
value.hidesBottomBarWhenPushed = true
}
}
super.setViewControllers(viewControllers, animated: animated)
}
override var childForStatusBarStyle: UIViewController? {
return self.topViewController
}
override var childForStatusBarHidden: UIViewController? {
return self.topViewController
}
}

View File

@ -0,0 +1,64 @@
//
// FATabBarController.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
class FATabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
let nav1 = getNavigation(FAHomeViewController(), "Home".localized, UIImage(named: "tabbar_home_icon"), UIImage(named: "tabbar_home_icon_selected"))
let nav2 = getNavigation(FARecommendViewController(), "Recommend".localized, UIImage(named: "tabbar_recommend_icon"), UIImage(named: "tabbar_recommend_icon_selected"))
let nav3 = getNavigation(FACollectViewController(), "Collect".localized, UIImage(named: "tabbar_collect_icon"), UIImage(named: "tabbar_collect_icon_selected"))
let nav4 = getNavigation(FAMeViewController(), "Me".localized, UIImage(named: "tabbar_me_icon"), UIImage(named: "tabbar_me_icon_selected"))
viewControllers = [nav1, nav2, nav3, nav4]
let appearance = UITabBarAppearance()
appearance.backgroundColor = .init(named: .color_0D0D0D)
appearance.backgroundImage = UIImage()
appearance.shadowColor = .clear
appearance.shadowImage = UIImage()
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.font : UIFont.font(ofSize: 10, weight: .init(500)),
.foregroundColor : UIColor(named: .color_777777)!
]
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.font : UIFont.font(ofSize: 10, weight: .init(500)),
.foregroundColor : UIColor(named: .color_3769FC)!
]
self.tabBar.scrollEdgeAppearance = appearance
self.tabBar.standardAppearance = appearance
self.tabBar.isTranslucent = false
}
override var childForStatusBarStyle: UIViewController? {
return self.selectedViewController
}
override var childForStatusBarHidden: UIViewController? {
return self.selectedViewController
}
}
extension FATabBarController {
private func getNavigation(_ viewController: UIViewController, _ title: String, _ image: UIImage?, _ selectedImage: UIImage?) -> UINavigationController {
let nav = FANavigationController(rootViewController: viewController)
nav.tabBarItem.title = title
nav.tabBarItem.image = image
nav.tabBarItem.selectedImage = selectedImage
return nav
}
}

View File

@ -0,0 +1,93 @@
//
// FAViewController.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
class FAViewController: UIViewController {
lazy var bgView: UIView = {
let view = UIImageView(image: UIImage(named: "背景"))
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
self.edgesForExtendedLayout = [.top]
if let navi = navigationController {
if navi.visibleViewController == self {
if navi.viewControllers.count > 1 {
configNavigationBack()
}
}
}
view.addSubview(bgView)
bgView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
func handleHeaderRefresh(_ completer: (() -> Void)?) {
completer?()
}
func handleFooterRefresh(_ completer: (() -> Void)?) {
completer?()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
}
extension UIViewController {
func configNavigationBack(_ imageName: String = "Frame 3011") {
let image = UIImage(named: imageName)
let leftBarButtonItem = UIBarButtonItem(image: image, style: .plain ,target: self,action: #selector(handleNavigationBack))
navigationItem.leftBarButtonItem = leftBarButtonItem
}
@objc func handleNavigationBack() {
self.fa_toLastViewController(animated: true)
}
func fa_toLastViewController(animated: Bool) {
if self.navigationController != nil
{
if self.navigationController?.viewControllers.count == 1
{
self.dismiss(animated: animated, completion: nil)
} else {
self.navigationController?.popViewController(animated: animated)
}
}
else if self.presentingViewController != nil {
self.dismiss(animated: animated, completion: nil)
}
}
}
extension UIViewController {
func fa_setNavigationStyle(backgroundColor: UIColor = .clear,
titleFont: UIFont = .font(ofSize: 18, weight: .bold),
titleColor: UIColor = .FFFFFF,
isTranslucent: Bool = true
) {
self.navigationController?.navigationBar.fa_setTranslucent(isTranslucent: isTranslucent)
self.navigationController?.navigationBar.fa_setBackgroundColor(backgroundColor: backgroundColor)
self.navigationController?.navigationBar.fa_setTitleTextAttributes(titleTextAttributes: [
NSAttributedString.Key.font : titleFont,
NSAttributedString.Key.foregroundColor : titleColor
])
}
}

View File

@ -0,0 +1,39 @@
//
// FADefine.swift
// Fableon
//
// Created by 鸿 on 2025/9/26.
//
import UIKit
///
let kFAOsVersion: String = UIDevice.current.systemVersion
let kBRAPPBundleIdentifier: String = (Bundle.main.infoDictionary!["CFBundleIdentifier"] as? String) ?? "0"
///app
let kFAAPPVersion: String = (Bundle.main.infoDictionary!["CFBundleShortVersionString"] as? String) ?? "0"
let kFAAPPBundleVersion: String = (Bundle.main.infoDictionary!["CFBundleVersion"] as? String) ?? "0"
let kFAAPPBundleName: String = (Bundle.main.infoDictionary!["CFBundleName"] as? String) ?? ""
let kFAAPPName: String = (Bundle.main.infoDictionary!["CFBundleDisplayName"] as? String) ?? ""
public func fa_swizzled_instanceMethod(_ prefix: String, oldClass: Swift.AnyClass!, oldSelector: String, newClass: Swift.AnyClass) {
let newSelector = prefix + "_" + oldSelector;
let originalSelector = NSSelectorFromString(oldSelector)
let swizzledSelector = NSSelectorFromString(newSelector)
let originalMethod = class_getInstanceMethod(oldClass, originalSelector)
let swizzledMethod = class_getInstanceMethod(newClass, swizzledSelector)
let isAdd = class_addMethod(oldClass, originalSelector, method_getImplementation(swizzledMethod!), method_getTypeEncoding(swizzledMethod!))
if isAdd {
class_replaceMethod(newClass, swizzledSelector, method_getImplementation(originalMethod!), method_getTypeEncoding(originalMethod!))
}else {
method_exchangeImplementations(originalMethod!, swizzledMethod!)
}
}

View File

@ -0,0 +1,65 @@
//
// CGMutablePath+FARoundedCorner.swift
// Fableon
//
// Created by 鸿 on 2025/9/28.
//
import UIKit
struct FARoundedCorner {
var topLeft:CGFloat = 0
var topRight:CGFloat = 0
var bottomLeft:CGFloat = 0
var bottomRight:CGFloat = 0
public static let zero = FARoundedCorner(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0)
public init(topLeft: CGFloat, topRight:CGFloat, bottomLeft:CGFloat, bottomRight:CGFloat) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
}
static func ==(v1:FARoundedCorner, v2:FARoundedCorner) -> Bool {
return v1.bottomLeft == v2.bottomLeft
&& v1.bottomRight == v2.bottomRight
&& v1.topLeft == v2.topLeft
&& v1.topRight == v2.topRight
}
static func !=(v1:FARoundedCorner, v2:FARoundedCorner) -> Bool {
return !(v1 == v2)
}
}
extension CGMutablePath {
func addRadiusRectangle(_ circulars: FARoundedCorner, rect: CGRect) {
let minX = rect.minX
let minY = rect.minY
let maxX = rect.maxX
let maxY = rect.maxY
//
let topLeftCenterX = minX + circulars.topLeft
let topLeftCenterY = minY + circulars.topLeft
let topRightCenterX = maxX - circulars.topRight
let topRightCenterY = minY + circulars.topRight
let bottomLeftCenterX = minX + circulars.bottomLeft
let bottomLeftCenterY = maxY - circulars.bottomLeft
let bottomRightCenterX = maxX - circulars.bottomRight
let bottomRightCenterY = maxY - circulars.bottomRight
//
addArc(center: CGPoint(x: topLeftCenterX, y: topLeftCenterY), radius: circulars.topLeft, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 3 / 2, clockwise: false)
//
addArc(center: CGPoint(x: topRightCenterX, y: topRightCenterY), radius: circulars.topRight, startAngle: CGFloat.pi * 3 / 2, endAngle: 0, clockwise: false)
//
addArc(center: CGPoint(x: bottomRightCenterX, y: bottomRightCenterY), radius: circulars.bottomRight, startAngle: 0, endAngle: CGFloat.pi / 2, clockwise: false)
//
addArc(center: CGPoint(x: bottomLeftCenterX, y: bottomLeftCenterY), radius: circulars.bottomLeft, startAngle: CGFloat.pi / 2, endAngle: CGFloat.pi, clockwise: false)
closeSubpath();
}
}

View File

@ -0,0 +1,23 @@
//
// Dictionary+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
extension Dictionary {
func toJsonString() -> String? {
do {
let data = try JSONSerialization.data(withJSONObject: self)
let jsonStr = String(data: data, encoding: .utf8)
return jsonStr
} catch {
}
return nil
}
}

View File

@ -0,0 +1,24 @@
//
// Font+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
import SwiftUICore
extension Font {
static func font(size: CGFloat, weight: Font.Weight) -> Font {
return Font.system(size: size, weight: weight)
}
}
extension UIFont {
static func font(ofSize: CGFloat, weight: UIFont.Weight) -> UIFont {
return UIFont.systemFont(ofSize: ofSize, weight: weight)
}
}

View File

@ -0,0 +1,41 @@
//
// String+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import Foundation
import SmartCodable
import YYCategories
extension String: SmartCodable {
static func timeZone() -> String {
let timeZone = NSTimeZone.local as NSTimeZone
let timeZoneSecondsFromGMT = timeZone.secondsFromGMT / 3600
return String(format: "GMT+0%d:00", timeZoneSecondsFromGMT)
}
func size(_ font: UIFont, _ size: CGSize) -> CGSize {
return (self as NSString).size(for: font, size: size, mode: .byWordWrapping)
}
}
extension String {
static let color_FFFFFF = "#FFFFFF"
static let color_000000 = "#000000"
static let color_3769FC = "#3769FC"
static let color_777777 = "#777777"
static let color_0D0D0D = "#0D0D0D"
static let color_81CAFF = "#81CAFF"
static let color_20A2FF = "#20A2FF"
static let color_DDEDFD = "#DDEDFD"
static let color_A8DBFF = "#A8DBFF"
static let color_BEDFFF = "#BEDFFF"
static let color_52A2F1 = "#52A2F1"
static let color_C7DEF5 = "#C7DEF5"
static let color_333333 = "#333333"
static let color_D9D9D9 = "#D9D9D9"
}

View File

@ -0,0 +1,80 @@
//
// SwiftUIExtension.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import SwiftUI
struct FAOnFirstAppear: ViewModifier {
@State private var hasAppeared = false
let action: () -> Void
func body(content: Content) -> some View {
content.onAppear {
if !hasAppeared {
hasAppeared = true
action()
}
}
}
}
struct FAGradientBorder: ViewModifier {
var colors: [Color] = [.red, .blue]
var lineWidth: CGFloat = 4
var cornerRadius: CGFloat = 16
var startPoint: UnitPoint = .topLeading
var endPoint: UnitPoint = .bottomTrailing
func body(content: Content) -> some View {
content
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(
LinearGradient(
gradient: Gradient(colors: colors),
startPoint: startPoint,
endPoint: endPoint
),
lineWidth: lineWidth
)
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
}
extension View {
func onFirstAppear(perform action: @escaping () -> Void) -> some View {
self.modifier(FAOnFirstAppear(action: action))
}
func gradientBorder(
colors: [Color] = [.red, .blue],
lineWidth: CGFloat = 4,
cornerRadius: CGFloat = 16,
startPoint: UnitPoint = .topLeading,
endPoint: UnitPoint = .bottomTrailing
) -> some View {
self.modifier(FAGradientBorder(colors: colors,
lineWidth: lineWidth,
cornerRadius: cornerRadius,
startPoint: startPoint,
endPoint: endPoint))
}
func setBackground() -> some View {
self.background(
Image("背景")
.resizable()
.scaledToFill()
.ignoresSafeArea()
.clipped()
)
}
}

View File

@ -0,0 +1,49 @@
//
// UINavigationBar+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/10/9.
//
import UIKit
extension UINavigationBarAppearance {
static func defaultAppearance() -> UINavigationBarAppearance {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.configureWithOpaqueBackground()
navBarAppearance.backgroundColor = .clear
navBarAppearance.backgroundEffect = nil
navBarAppearance.shadowColor = UIColor.clear
navBarAppearance.titleTextAttributes = [
NSAttributedString.Key.font : UIFont.font(ofSize: 18, weight: .bold),
NSAttributedString.Key.foregroundColor : UIColor.FFFFFF
]
return navBarAppearance
}
}
extension UINavigationBar {
func fa_setTranslucent(isTranslucent: Bool) {
self.isTranslucent = isTranslucent
}
func fa_setBackgroundColor(backgroundColor: UIColor?) {
let appearance = self.standardAppearance
appearance.backgroundColor = backgroundColor
self.standardAppearance = appearance
self.scrollEdgeAppearance = appearance
}
func fa_setTitleTextAttributes(titleTextAttributes: [NSAttributedString.Key : Any]?) {
let appearance = self.standardAppearance
if let titleTextAttributes = titleTextAttributes {
appearance.titleTextAttributes = titleTextAttributes
}
self.scrollEdgeAppearance = appearance
self.standardAppearance = appearance
}
}

View File

@ -0,0 +1,40 @@
//
// Screen+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
extension UIScreen {
static var screen: UIScreen {
return UIScreen.main
}
static var width: CGFloat {
return UIScreen.main.bounds.width
}
static var height: CGFloat {
return UIScreen.main.bounds.height
}
static var safeTop: CGFloat {
return FATool.keyWindow?.safeAreaInsets.top ?? 20
}
static var safeBottom: CGFloat {
return FATool.keyWindow?.safeAreaInsets.bottom ?? 0
}
static var navBarHeight: CGFloat {
return safeTop + 44
}
static var tabBarHeight: CGFloat {
return safeBottom + 49
}
}

View File

@ -0,0 +1,70 @@
//
// UIScrollView+FARefresh.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import MJRefresh
extension UIScrollView {
func fa_addRefreshHeader(insetTop: CGFloat = 0, block: (() -> Void)?) {
self.mj_header = MJRefreshNormalHeader(refreshingBlock: {
block?()
})
self.mj_header?.ignoredScrollViewContentInsetTop = insetTop
}
func fa_addRefreshFooter(insetBottom: CGFloat = 0, block: (() -> Void)?) {
let footer = MJRefreshAutoNormalFooter(refreshingBlock: {
block?()
})
footer.ignoredScrollViewContentInsetBottom = insetBottom
self.mj_footer = footer
}
func fa_addRefreshBackFooter(insetBottom: CGFloat = 0, block: (() -> Void)?) {
self.mj_footer = MJRefreshBackNormalFooter(refreshingBlock: {
block?()
})
self.mj_footer?.ignoredScrollViewContentInsetBottom = insetBottom
}
func fa_endHeaderRefreshing() {
self.mj_header?.endRefreshing()
}
func fa_endFooterRefreshing() {
if self.mj_footer?.state == .noMoreData { return }
self.mj_footer?.endRefreshing()
}
///
func fa_resetNoMoreData() {
self.mj_footer?.resetNoMoreData()
}
func fa_endRefreshingWithNoMoreData() {
self.mj_footer?.endRefreshingWithNoMoreData()
}
func fa_updateNoMoreDataState(_ hasNextPage: Bool?) {
if hasNextPage == false {
self.fa_endRefreshingWithNoMoreData()
} else {
self.fa_resetNoMoreData()
}
if self.mj_totalDataCount() == 0 {
self.mj_footer?.isHidden = true
} else {
self.mj_footer?.isHidden = false
}
}
}

View File

@ -0,0 +1,21 @@
//
// UIStackView+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
extension UIStackView {
func fa_removeAllArrangedSubview() {
let arrangedSubviews = self.arrangedSubviews
arrangedSubviews.forEach {
self.removeArrangedSubview($0)
$0.removeFromSuperview()
}
}
}

View File

@ -0,0 +1,95 @@
//
// UIView+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/9/26.
//
import UIKit
extension UIView {
fileprivate struct AssociatedKeys {
static var fa_roundedCorner: Int?
static var fa_effect: Int?
}
@objc public static func fa_Awake() {
fa_swizzled_instanceMethod("fa", oldClass: self, oldSelector: "layoutSubviews", newClass: self)
}
@objc func fa_layoutSubviews() {
fa_layoutSubviews()
_updateRoundedCorner()
if let effectView = effectView, effectView.frame != self.bounds {
effectView.frame = self.bounds
}
}
}
//MARK: -------------- --------------
extension UIView {
private var roundedCorner: FARoundedCorner? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.fa_roundedCorner) as? FARoundedCorner
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.fa_roundedCorner, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
///
func fa_setRoundedCorner(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
//
self.roundedCorner = FARoundedCorner(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
_updateRoundedCorner()
}
private func _updateRoundedCorner() {
guard let roundedCorner = self.roundedCorner else { return }
let rect = self.bounds
let path = CGMutablePath()
path.addRadiusRectangle(roundedCorner, rect: rect)
let maskLayer = CAShapeLayer()
maskLayer.frame = self.bounds
maskLayer.path = path
self.layer.mask = maskLayer
}
}
//MARK: -------------- --------------
extension UIView {
private var effectView: UIVisualEffectView? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.fa_effect) as? UIVisualEffectView
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.fa_effect, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
///
func fa_addEffectView(style: UIBlurEffect.Style = .light) {
if self.effectView == nil {
let blur = UIBlurEffect(style: style)
let effectView = UIVisualEffectView(effect: blur)
effectView.isUserInteractionEnabled = false
self.addSubview(effectView)
self.sendSubviewToBack(effectView)
self.effectView = effectView
}
}
///
func fa_removeEffectView() {
self.effectView?.removeFromSuperview()
self.effectView = nil
}
}

View File

@ -0,0 +1,39 @@
//
// UserDefaults+FAAdd.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import Foundation
extension UserDefaults {
static func fa_setObject(_ obj: NSSecureCoding?, forKey key: String) {
let defaults = UserDefaults.standard
guard let obj = obj else {
defaults.removeObject(forKey: key)
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: obj, requiringSecureCoding: true)
defaults.set(data, forKey: key)
} catch {
print("Error archiving object: \(error)")
}
}
static func fa_object<T: NSObject & NSSecureCoding>(forKey key: String, as type: T.Type) -> T? {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: key) else {
return nil
}
do {
let object = try NSKeyedUnarchiver.unarchivedObject(ofClass: type, from: data)
return object
} catch {
print("Error unarchiving object: \(error)")
return nil
}
}
}

View File

@ -0,0 +1,161 @@
//
// FAAPI.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
extension FAAPI {
/// [ "state" : isCollect, "id" : shortPlayId,]
static let updateShortCollectStateNotification = NSNotification.Name(rawValue: "FAAPI.updateShortCollectStateNotification")
}
struct FAAPI {
static func requestHomeModulesData(completer: ((_ list: [FAHomeModuleItem]?) -> Void)?) {
FANetworkManager.manager.request(FABaseURL + "/home/all-modules", method: .get) { (response: FANetworkManager.Response<FANetworkManager.List<FAHomeModuleItem>>) in
completer?(response.data?.list)
}
}
static func requestShortDetailData(shortPlayId: String, activityId: String? = nil, completer: ((FAShortDetailModel?, Int?, String?) -> Void)?) {
var parameters: [String : Any] = [
"short_play_id" : shortPlayId,
"video_id" : "0"
]
if let activityId = activityId {
parameters["activity_id"] = activityId
}
FANetworkManager.manager.request(FABaseURL + "/getVideoDetails", method: .get, parameters: parameters) { (response: FANetworkManager.Response<FAShortDetailModel>) in
if response.isSuccess {
completer?(response.data, response.code, response.msg)
} else {
completer?(nil, response.code, response.msg)
}
}
}
static func requestCreatePlayHistory(videoId: String?, shortPlayId: String?) {
guard let shortPlayId = shortPlayId else { return }
let parameters = [
"video_id" : videoId ?? "0",
"short_play_id" : shortPlayId
]
FANetworkManager.manager.request(FABaseURL + "/createHistory", method: .post, parameters: parameters, isToast: false) { (response: FANetworkManager.Response<String>) in
}
}
///
static func requestPlayHistorys(page: Int, pageSize: Int = 20, completer: ((_ listModel: FANetworkManager.List<FAShortPlayModel>?) -> Void)?) {
let parameters = [
"current_page" : page,
"page_size" : pageSize
]
FANetworkManager.manager.request(FABaseURL + "/myHistorys", method: .get, parameters: parameters, isToast: false) { (response: FANetworkManager.Response<FANetworkManager.List<FAShortPlayModel>>) in
completer?(response.data)
}
}
///
static func requestShortCollect(isCollect: Bool, shortPlayId: String, videoId: String?, isLoding: Bool = true, success: (() -> Void)?, failure: (() -> Void)? = nil) {
let path: String
if isCollect {
path = "/collect"
} else {
path = "/cancelCollect"
}
var parameters: [String : Any] = [
"short_play_id" : shortPlayId,
]
if let videoId = videoId {
parameters["video_id"] = videoId
}
FANetworkManager.manager.request(FABaseURL + path,
parameters: parameters,
isLoding: true) { (response: FANetworkManager.Response<FAShortDetailModel>) in
if response.isSuccess {
success?()
NotificationCenter.default.post(name: FAAPI.updateShortCollectStateNotification, object: nil, userInfo: [
"state" : isCollect,
"id" : shortPlayId,
])
} else {
failure?()
}
}
}
///
static func requestRecommendVideo(page: Int, completer: ((_ listModel: FANetworkManager.List<FAShortPlayModel>?) -> Void)?) {
let parameters: [String : Any] = [
"page_size" : 20,
"current_page" : page
]
FANetworkManager.manager.request(FABaseURL + "/getRecommands",
method: .get,
parameters: parameters,
isLoding: false) { (response: FANetworkManager.Response<FANetworkManager.List<FAShortPlayModel>>) in
completer?(response.data)
}
}
///
static func requestCollectList(page: Int, completer: ((_ listModel: FANetworkManager.List<FAShortPlayModel>?) -> Void)?) {
let parameters: [String : Any] = [
"page_size" : 20,
"current_page" : page
]
FANetworkManager.manager.request(FABaseURL + "/myCollections",
method: .get,
parameters: parameters,
isLoding: false) { (response: FANetworkManager.Response<FANetworkManager.List<FAShortPlayModel>>) in
completer?(response.data)
}
}
///
static func requestHotSearchData(completer: ((_ list: [FAShortPlayModel]?) -> Void)?) {
FANetworkManager.manager.request(FABaseURL + "/search/hots",
method: .get,
parameters: nil,
isToast: false) { (response: FANetworkManager.Response<FANetworkManager.List<FAShortPlayModel>>) in
completer?(response.data?.list)
}
}
///
static func requestSearch(text: String, completer: ((_ list: [FAShortPlayModel]?) -> Void)?) {
let parameters = [
"search" : text
]
FANetworkManager.manager.request(FABaseURL + "/search",
method: .get,
parameters: parameters,
isToast: true) { (response: FANetworkManager.Response<FANetworkManager.List<FAShortPlayModel>>) in
completer?(response.data?.list)
}
}
}

View File

@ -0,0 +1,20 @@
//
// FAAPIPath.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
let FABaseURL = "https://api-breeltv.breeltv.com/reel"
let FAWebBaseURL = "https://www.breeltv.com"
let FACampaignWebURL = "https://campaign.breeltv.com"
///
let kFAFeedBackHomeWebUrl = FACampaignWebURL + "/pages/leave/index"
///
let kFAFeedBackListWebUrl = FACampaignWebURL + "/pages/leave/list"
///
let kFAFeedBackDetailWebUrl = FACampaignWebURL + "/pages/leave/detail"

View File

@ -0,0 +1,95 @@
//
// FACryptorService.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import Foundation
struct FACryptorService {
static func decrypt(data: String) -> String {
guard data.hasPrefix("$") else {
return data
}
let decryptedData = deStrBytes(data: data)
return String(data: decryptedData, encoding: .utf8) ?? ""
}
static func deStrBytes(data: String) -> Data {
let hexData = String(data.dropFirst())
var bytes = Data()
var index = hexData.startIndex
while index < hexData.endIndex {
let nextIndex = hexData.index(index, offsetBy: 2, limitedBy: hexData.endIndex) ?? hexData.endIndex
let byteString = String(hexData[index..<nextIndex])
if let byte = UInt8(byteString, radix: 16) {
bytes.append(byte)
}
index = nextIndex
}
return de(data: bytes)
}
//
static func de(data: Data) -> Data {
guard !data.isEmpty else {
return data
}
let saltLen = Int(data[data.startIndex])
guard data.count >= 1 + saltLen else {
return data
}
let salt = data.subdata(in: 1..<1+saltLen)
let encryptedData = data.subdata(in: 1+saltLen..<data.count)
return deWithSalt(data: encryptedData, salt: salt)
}
// 使
static func deWithSalt(data: Data, salt: Data) -> Data {
let decryptedData = cxEd(data: data)
return removeSalt(data: decryptedData, salt: salt)
}
// /
static func cxEd(data: Data) -> Data {
return Data(data.map { $0 ^ 0xFF })
}
//
static func removeSalt(data: Data, salt: Data) -> Data {
guard !salt.isEmpty else {
return data
}
var result = Data()
let saltBytes = [UInt8](salt)
let saltCount = saltBytes.count
for (index, byte) in data.enumerated() {
let saltByte = saltBytes[index % saltCount]
let decryptedByte = calRemoveSalt(v: byte, s: saltByte)
result.append(decryptedByte)
}
return result
}
//
static func calRemoveSalt(v: UInt8, s: UInt8) -> UInt8 {
if v >= s {
return v - s
} else {
return UInt8(0xFF) - (s - v) + 1
}
}
}

View File

@ -0,0 +1,232 @@
//
// FANetworkManager.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import Foundation
import Alamofire
import AdSupport
import SmartCodable
import YYCategories
///
class FANetworkManager {
static let manager = FANetworkManager()
private let operationQueue = OperationQueue()
private var tokenOperation: BlockOperation?
//
func request<T: SmartCodable>(
_ url: String,
method: HTTPMethod = .post,
parameters: Parameters? = nil,
isLoding: Bool = false,
isToast: Bool = true,
completion: ((_ response: FANetworkManager.Response<T>) -> Void)?
) {
if FALogin.manager.token == nil, !isTokenUrl(url) {
self.requestUserToken(completer: nil)
}
if let _ = self.tokenOperation, !isTokenUrl(url) {
requestAddQueue(url, method: method, parameters: parameters, isLoding: isLoding, isToast: isToast, completion: completion)
return
}
_request(url, method: method, parameters: parameters, isLoding: isLoding, isToast: isToast, completion: completion)
}
private func _request<T: SmartCodable>(
_ url: String,
method: HTTPMethod = .get,
parameters: Parameters? = nil,
isLoding: Bool,
isToast: Bool,
completion: ((_ response: FANetworkManager.Response<T>) -> Void)?
) {
if isLoding {
FAHUD.show()
}
AF.request(
url,
method: method,
parameters: parameters,
encoding: method == .get ? URLEncoding.default : JSONEncoding.default,
headers: self.headers
)
.responseString(completionHandler: { response in
if isLoding {
FAHUD.dismiss()
}
let code = response.response?.statusCode
if code == 401 || code == 402 || code == 403 {
if self.isTokenUrl(url) {
var response = FANetworkManager.Response<T>()
response.code = -1
completion?(response)
} else {
self.requestUserToken {
if FALogin.manager.token != nil {
FALogin.manager.requestUserInfo(completer: nil)
}
}
if let _ = self.tokenOperation, !self.isTokenUrl(url) {
self.requestAddQueue(url, method: method, parameters: parameters, isLoding: isLoding, isToast: isToast, completion: completion)
}
}
return
}
switch response.result {
case .success(let data):
let decrypted = FACryptorService.decrypt(data: data)
if let parameters = parameters {
debugLog(parameters)
}
debugLog(url)
debugLog(decrypted)
if let response = FANetworkManager.Response<T>.deserialize(from: decrypted) {
completion?(response)
} else {
if isToast {
FAToast.show(text: "Error".localized)
}
var res = FANetworkManager.Response<T>()
res.code = -1
res.msg = "解析错误"
completion?(res)
}
case .failure(let error):
if isToast {
FAToast.show(text: "network_error_01".localized)
}
var res = FANetworkManager.Response<T>()
res.code = error.responseCode
res.msg = error.localizedDescription
completion?(res)
}
})
}
}
extension FANetworkManager {
private func requestUserToken(completer: (() -> Void)?) {
guard self.tokenOperation == nil else {
completer?()
return
}
self.tokenOperation = BlockOperation(block: {
let semaphore = DispatchSemaphore(value: 0)
FALogin.manager.requestUserToken {
do { semaphore.signal() }
self.tokenOperation = nil
completer?()
}
semaphore.wait()
})
operationQueue.addOperation(self.tokenOperation!)
}
private func requestAddQueue<T: SmartCodable>(_ url: String,
method: HTTPMethod,
parameters: Parameters?,
isLoding: Bool,
isToast: Bool,
completion: ((_ response: FANetworkManager.Response<T>) -> Void)?) {
guard let tokenOperation = self.tokenOperation else { return }
let requestOperation = BlockOperation {
let semaphore = DispatchSemaphore(value: 0)
self._request(url, method: method, parameters: parameters, isLoding: isLoding, isToast: isToast) { response in
semaphore.signal()
completion?(response)
}
semaphore.wait()
}
///
requestOperation.addDependency(tokenOperation)
operationQueue.addOperation(requestOperation)
}
}
extension FANetworkManager {
private func isTokenUrl(_ url: String) -> Bool {
return url.contains("/customer/register")
}
private var headers: HTTPHeaders {
let token = FALogin.manager.token?.token ?? ""
let dic = [
"authorization" : token,
"system-version" : UIDevice.current.systemVersion,
"lang-key" : "en",
"time-zone" : String.timeZone(),
"app-version" : (Bundle.main.infoDictionary!["CFBundleShortVersionString"] as? String) ?? "",
"brand" : "apple", //
"app-name" : "Fableon",
"system-type" : "ios",
"model" : UIDevice.current.machineModelName ?? "",
"idfa" : ASIdentifierManager.shared().advertisingIdentifier.uuidString,
"device-id" : FADeviceIDManager.shared.id, //id
"device-gaid" : UIDevice.current.identifierForVendor?.uuidString ?? ""
]
return HTTPHeaders(dic)
}
}
extension FANetworkManager {
struct Response<T : SmartCodable>: SmartCodable {
var code: Int?
var data: T?
var msg: String?
var isSuccess: Bool {
return code == 200
}
}
struct List<T: SmartCodable>: SmartCodable {
var list: [T]?
var pagination: Pagination?
var hasNextPage: Bool {
let totalPage = pagination?.page_total ?? 0
let currentPage = pagination?.current_page ?? 0
return totalPage > currentPage
}
}
struct Pagination: SmartCodable {
var current_page: Int?
var page_size: Int?
var page_total: Int?
var total_size: Int?
}
}

View File

@ -0,0 +1,73 @@
//
// FANetworkMonitor.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
import Network
class FANetworkMonitor {
static let manager = FANetworkMonitor()
///
var isReachable: Bool?
private var connectionType: NWInterface.InterfaceType?
private var status: NWPath.Status?
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitorQueue")
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
self.status = path.status
if path.usesInterfaceType(.wifi) {
self.connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
self.connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
self.connectionType = .wiredEthernet
} else {
self.connectionType = nil
}
if path.status == .satisfied, self.connectionType != nil {
if self.isReachable == false {
self.isReachable = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: FANetworkMonitor.networkStatusDidChangeNotification, object: nil)
}
} else {
self.isReachable = true
}
} else {
if self.isReachable == true {
self.isReachable = false
DispatchQueue.main.async {
NotificationCenter.default.post(name: FANetworkMonitor.networkStatusDidChangeNotification, object: nil)
}
} else {
self.isReachable = false
}
}
}
monitor.start(queue: queue)
}
func stopMonitoring() {
monitor.cancel()
}
}
extension FANetworkMonitor {
///
@objc static let networkStatusDidChangeNotification = NSNotification.Name(rawValue: "FANetworkMonitor.networkStatusDidChangeNotification")
}

View File

@ -0,0 +1,22 @@
//
// FACollectionView.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
class FACollectionView: UICollectionView {
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
self.backgroundColor = .clear
self.contentInsetAdjustmentBehavior = .never
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,101 @@
//
// FAImageView.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import Kingfisher
@IBDesignable
class FAImageView: UIImageView {
var placeholderColor = UIColor._8_B_8_B_8_B
var placeholderImage = UIImage(named: "placeholder_image")
private lazy var placeholderImageView: UIImageView = {
let imageView = UIImageView(image: placeholderImage)
imageView.isHidden = true
imageView.contentMode = .scaleAspectFit
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
override init(image: UIImage?) {
super.init(image: image)
_init()
}
override init(image: UIImage?, highlightedImage: UIImage?) {
super.init(image: image, highlightedImage: highlightedImage)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
override func awakeFromNib() {
super.awakeFromNib()
_init()
}
func _init() {
self.contentMode = .scaleAspectFill
self.layer.masksToBounds = true
if image == nil {
self.backgroundColor = self.placeholderColor
placeholderImageView.isHidden = false
}
addSubview(placeholderImageView)
}
override var image: UIImage? {
didSet {
if self.backgroundColor == nil && image == nil {
self.backgroundColor = self.placeholderColor
} else if image != nil {
if self.backgroundColor == self.placeholderColor {
self.backgroundColor = nil
}
}
if image == nil {
placeholderImageView.isHidden = false
} else {
placeholderImageView.isHidden = true
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
placeholderImageView.frame = .init(x: 0, y: 0, width: self.bounds.width * (2 / 3), height: self.bounds.height * (2 / 3))
placeholderImageView.center = .init(x: self.bounds.width / 2, y: self.bounds.height / 2)
}
}
extension UIImageView {
func fa_setImage(_ url: String?, placeholder: UIImage? = nil, completer: ((_ image: UIImage?, _ url: URL?) -> Void)? = nil) {
self.kf.setImage(with: URL(string: url ?? ""), placeholder: placeholder, options: nil) { result in
switch result {
case .success(let value):
completer?(value.image, value.source.url)
default :
completer?(nil, nil)
break
}
}
}
}

View File

@ -0,0 +1,78 @@
//
// FAPanModalContentView.swift
// Fableon
//
// Created by 鸿 on 2025/9/23.
//
import UIKit
import HWPanModal
class FAPanModalContentView: HWPanModalContentView {
var contentHeight = UIScreen.height * (2 / 3)
var mainScrollView: UIScrollView?
///UI contentSize
func setNeedsLayoutUpdate() {
self.panModalSetNeedsLayoutUpdate()
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = ._000000.withAlphaComponent(0.5)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: HWPanModalPresentable
override func panScrollable() -> UIScrollView? {
return mainScrollView
}
override func longFormHeight() -> PanModalHeight {
return PanModalHeightMake(.content, contentHeight)
}
override func showDragIndicator() -> Bool {
return false
}
override func backgroundConfig() -> HWBackgroundConfig {
let config = HWBackgroundConfig()
config.backgroundAlpha = 0.6
return config
}
override func allowsTapBackgroundToDismiss() -> Bool {
return true
}
override func allowsDragToDismiss() -> Bool {
return false
}
override func allowsPullDownWhenShortState() -> Bool {
return false
}
override func showsScrollableVerticalScrollIndicator() -> Bool {
return false
}
override func springDamping() -> CGFloat {
return 1
}
override func cornerRadius() -> CGFloat {
return 24
}
}

View File

@ -0,0 +1,21 @@
//
// FAScrollView.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FAScrollView: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
self.contentInsetAdjustmentBehavior = .never
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,49 @@
//
// FATableView.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FATableView: UITableView {
var insetGroupedMargins: CGFloat = 15
override init(frame: CGRect, style: UITableView.Style) {
super.init(frame: frame, style: style)
separatorColor = .FFFFFF.withAlphaComponent(0.4)
separatorInset = .init(top: 0, left: 16, bottom: 0, right: 16)
self.backgroundColor = .clear
self.contentInsetAdjustmentBehavior = .never
if style == .insetGrouped {
sectionFooterHeight = 14
sectionHeaderHeight = 0.1
} else if style == .plain {
if #available(iOS 15.0, *) {
sectionHeaderTopPadding = 0
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var layoutMargins: UIEdgeInsets {
set {
super.layoutMargins = newValue
}
get {
var margins = super.layoutMargins
if self.style == .insetGrouped {
margins.left = self.safeAreaInsets.left + insetGroupedMargins
margins.right = self.safeAreaInsets.right + insetGroupedMargins
}
return margins
}
}
}

View File

@ -0,0 +1,47 @@
//
// FATableViewCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FATableViewCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
private func _init() {
self.layer.rasterizationScale = UIScreen.main.scale
self.layer.shouldRasterize = true
self.selectionStyle = .none
self.backgroundColor = .clear
}
}
extension UITableViewCell {
var fa_tableView: UITableView? {
return self.value(forKey: "_tableView") as? UITableView
}
}

View File

@ -0,0 +1,68 @@
//
// FAAppWebViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
class FAAppWebViewController: FABaseWebViewController {
var id: String?
private var receiveDataCount = 0
var theme: String? = "theme_2"
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
override func fa_webViewDidFinishLoad(_ webView: FAWebView) {
super.fa_webViewDidFinishLoad(webView)
receiveDataCount = 0
receiveDataFromNative()
}
}
extension FAAppWebViewController {
func receiveDataFromNative() {
receiveDataCount += 1
if receiveDataCount > 10 { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self = self else { return }
var dic = [
"token" : FALogin.manager.token?.token ?? "",
"time_zone" : String.timeZone(),
"lang" : FALocalized.manager.currentLocalizedKey,
"type" : "ios",
]
if let theme = theme {
dic["theme"] = theme
}
if let id = id {
dic["id"] = id
}
if let json = dic.toJsonString() {
let js = "receiveDataFromNative(\(json))"
self.webView.evaluateJavaScript(js) { [weak self] _, error in
guard let self = self else { return }
if error != nil {
self.receiveDataFromNative()
}
}
}
}
}
}

View File

@ -0,0 +1,112 @@
//
// FABaseWebViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
import WebKit
class FABaseWebViewController: FAViewController {
var webUrl: String?
///
var autoTitle = true
var needAutoRefresh = true
private(set) lazy var webView: FAWebView = {
let controller = WKUserContentController()
let config = WKWebViewConfiguration()
config.userContentController = controller
config.preferences.javaScriptEnabled = true
/** JS */
config.preferences.javaScriptCanOpenWindowsAutomatically = true
let webView = FAWebView(frame: self.view.bounds, configuration: config)
webView.delegate = self
return webView
}()
override func viewDidLoad() {
super.viewDidLoad()
// self.edgesForExtendedLayout = []
fa_setupLayout()
if let url = webUrl {
self.load(webUrl: url)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.fa_setNavigationStyle()
}
func load(webUrl: String) {
let str: String = webUrl
guard let url = URL(string: str) else { return }
let request = URLRequest(url: url, timeoutInterval: 30)
self.webView.load(request)
}
func reload() {
self.webView.reload()
}
}
extension FABaseWebViewController {
private func fa_setupLayout() {
self.view.addSubview(webView)
self.webView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(0)
make.right.equalToSuperview().offset(0)
make.bottom.equalToSuperview().offset(0)
make.top.equalToSuperview().offset(UIScreen.navBarHeight)
}
}
}
//MARK: -------------- VPWebViewDelegate --------------
extension FABaseWebViewController: FAWebViewDelegate {
func fa_webView(_ webView: FAWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool {
self.webView.isHidden = false
return true
}
func fa_webViewDidStartLoad(_ webView: FAWebView) {
FAHUD.show(containerView: self.view)
}
func fa_webView(webView: FAWebView, didChangeTitle title: String) {
if autoTitle {
self.title = title
}
}
func fa_webViewDidFinishLoad(_ webView: FAWebView) {
self.webView.isHidden = false
FAHUD.dismiss()
}
func fa_webView(_ webView: FAWebView, didFailLoadWithError error: any Error) {
FAHUD.dismiss()
}
func fa_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
}
}

View File

@ -0,0 +1,147 @@
//
// FAWebView.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
@preconcurrency import WebKit
import YYText
@objc protocol FAWebViewDelegate: NSObjectProtocol {
@objc optional func fa_webView(_ webView: FAWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool
@objc optional func fa_webViewDidStartLoad(_ webView: FAWebView)
@objc optional func fa_webViewDidFinishLoad(_ webView: FAWebView)
@objc optional func fa_webView(_ webView: FAWebView, didFailLoadWithError error: Error)
///
@objc optional func fa_webView(webView: FAWebView, didChangeProgress progress: CGFloat)
///
@objc optional func fa_webView(webView: FAWebView, didChangeTitle title: String)
///web
@objc optional func fa_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
}
class FAWebView: WKWebView {
weak var delegate: FAWebViewDelegate?
private(set) var scriptMessageHandlerArray: [String] = [
]
deinit {
self.removeObserver(self, forKeyPath: "estimatedProgress")
self.removeObserver(self, forKeyPath: "title")
}
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
super.init(frame: frame, configuration: configuration)
addScriptMessageHandler()
_setupInit()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func _setupInit() {
self.isOpaque = false
self.navigationDelegate = self
self.addObserver(self, forKeyPath: "estimatedProgress", options: .new, context: nil)
self.addObserver(self, forKeyPath: "title", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if object as? FAWebView == self {
if keyPath == "estimatedProgress", let progress = change?[NSKeyValueChangeKey.newKey] as? CGFloat {
self.delegate?.fa_webView?(webView: self, didChangeProgress: progress)
} else if keyPath == "title", let title = change?[NSKeyValueChangeKey.newKey] as? String {
self.delegate?.fa_webView?(webView: self, didChangeTitle: title)
}
}
}
func load(urlStr: String) {
guard let url = URL(string: urlStr) else { return }
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 30)
self.load(request)
}
func removeScriptMessageHandler() {
self.scriptMessageHandlerArray.forEach{
configuration.userContentController.removeScriptMessageHandler(forName: $0)
}
}
func addScriptMessageHandler() {
self.scriptMessageHandlerArray.forEach{
configuration.userContentController.add(YYTextWeakProxy(target: self) as! WKScriptMessageHandler, name: $0)
}
}
}
//MARK:-------------- WKNavigationDelegate --------------
extension FAWebView: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
decisionHandler(.allow);
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = navigationAction.request.url,
url.scheme != "http",
url.scheme != "https"
{
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
if let result = self.delegate?.fa_webView?(self, shouldStartLoadWith: navigationAction) {
if result {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
}
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
self.delegate?.fa_webViewDidStartLoad?(self)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.delegate?.fa_webViewDidFinishLoad?(self)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
self.delegate?.fa_webView?(self, didFailLoadWithError: error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
self.delegate?.fa_webView?(self, didFailLoadWithError: error)
}
}
//MARK:-------------- WKScriptMessageHandler --------------
extension FAWebView: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
self.delegate?.fa_userContentController?(userContentController, didReceive: message)
}
}

View File

@ -0,0 +1,247 @@
//
// FAHomeViewController.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
import SwiftUI
import SnapKit
class FAHomeViewController: FAViewController {
private var viewModel = FAHomeViewModel()
private lazy var cvLayout: FAWaterfallFlowLayout = {
let layout = FAWaterfallFlowLayout()
layout.delegate = self
return layout
}()
private lazy var collectionView: FACollectionView = {
let view = FACollectionView(frame: .zero, collectionViewLayout: cvLayout)
view.delegate = self
view.dataSource = self
view.contentInset = .init(top: 20, left: 0, bottom: 10, right: 0)
view.register(FAHomeBannerContentCell.self, forCellWithReuseIdentifier: "FAHomeBannerCell")
view.register(FAHomeMustSeeContentCell.self, forCellWithReuseIdentifier: "FAHomeMustSeeContentCell")
view.register(FAHomeNewContentCell.self, forCellWithReuseIdentifier: "FAHomeNewContentCell")
view.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
view.register(UINib(nibName: "FAHomeRecommendedCell", bundle: nil), forCellWithReuseIdentifier: "FAHomeRecommendedCell")
view.register(UINib(nibName: "FAHomeSectionTitleView", bundle: nil), forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "FAHomeSectionTitleView")
view.register(UICollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "footer")
view.fa_addRefreshHeader(insetTop: view.contentInset.top) { [weak self] in
self?.handleHeaderRefresh(nil)
}
return view
}()
private lazy var titleView: UIView = {
let view = UIImageView(image: UIImage(named: "Thalire"))
return view
}()
private lazy var searchButton: UIButton = {
let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in
guard let self = self else { return }
let vc = FASearchViewController()
self.navigationController?.pushViewController(vc, animated: true)
}))
button.setImage(UIImage(named: "首页搜索i_ic"), for: .normal)
return button
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: FANetworkMonitor.networkStatusDidChangeNotification, object: nil)
fa_setupLayout()
self.viewModel.requestHomeData { [weak self] in
self?.collectionView.reloadData()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
self.viewModel.requestHomeData { [weak self] in
self?.collectionView.reloadData()
self?.collectionView.fa_endHeaderRefreshing()
}
}
@objc private func networkStatusDidChangeNotification() {
if self.viewModel.dataArr.isEmpty, FANetworkMonitor.manager.isReachable == true {
self.viewModel.requestHomeData { [weak self] in
self?.collectionView.reloadData()
}
}
}
}
extension FAHomeViewController {
private func fa_setupLayout() {
view.addSubview(titleView)
view.addSubview(searchButton)
view.addSubview(collectionView)
titleView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerY.equalTo(self.view.snp.top).offset(UIScreen.safeTop + (UIScreen.navBarHeight - UIScreen.safeTop) / 2)
}
searchButton.snp.makeConstraints { make in
make.centerY.equalTo(titleView)
make.right.equalToSuperview().offset(-16)
}
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(UIScreen.navBarHeight)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension FAHomeViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let item = self.viewModel.dataArr[indexPath.section]
if item.type == .banner {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FAHomeBannerCell", for: indexPath) as! FAHomeBannerContentCell
cell.moduleItem = item.data as? FAHomeModuleItem
cell.viewModel = self.viewModel
return cell
} else if item.type == .mustSee {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FAHomeMustSeeContentCell", for: indexPath) as! FAHomeMustSeeContentCell
cell.configure(self.viewModel)
return cell
} else if item.type == .new {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FAHomeNewContentCell", for: indexPath) as! FAHomeNewContentCell
cell.configure(viewModel)
return cell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FAHomeRecommendedCell", for: indexPath) as! FAHomeRecommendedCell
cell.model = (item.data as? FAHomeModuleItem)?.list[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
if kind == UICollectionView.elementKindSectionHeader {
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "FAHomeSectionTitleView", for: indexPath)
return view
}
return collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "footer", for: indexPath)
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return self.viewModel.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let item = self.viewModel.dataArr[section]
if item.type == .recommended {
return (item.data as? FAHomeModuleItem)?.list.count ?? 0
}
return 1
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let item = self.viewModel.dataArr[indexPath.section]
guard item.type == .recommended else { return }
let model = (item.data as? FAHomeModuleItem)?.list[indexPath.row]
self.viewModel.pushPlayerDetail(model)
}
}
//MARK: FAWaterfallMutiSectionDelegate
extension FAHomeViewController: FAWaterfallMutiSectionDelegate {
func heightForRowAtIndexPath(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, indexPath: IndexPath, itemWidth: CGFloat) -> CGFloat {
let homeItem = self.viewModel.dataArr[indexPath.section]
if homeItem.type == .recommended, let model = (homeItem.data as? FAHomeModuleItem)?.list[indexPath.row] {
if model.cellHeight > 0 {
return model.cellHeight
} else {
let size = CGSize.init(width: floor((UIScreen.width - 32 - 13) / 2) - 24, height: 1000)
let height = 219 + 6 + 12 + (model.name?.size(.font(ofSize: 14, weight: .medium), size).height ?? 0)
model.cellHeight = height
return height
}
}
return homeItem.cellHeight
}
func columnNumber(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> Int {
let homeItem = self.viewModel.dataArr[section]
if homeItem.type == .recommended {
return 2
}
return 1
}
func referenceSizeForHeader(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGSize {
let item = self.viewModel.dataArr[section]
guard item.type == .recommended else { return .zero }
return .init(width: UIScreen.width, height: 40)
}
func spacingWithLastSection(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
if section == 0 {
return 0
}
return 25
}
func insetForSection(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> UIEdgeInsets {
let homeItem = self.viewModel.dataArr[section]
if homeItem.type == .recommended {
return .init(top: 0, left: 16, bottom: 0, right: 16)
} else {
return .zero
}
}
func lineSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
let homeItem = self.viewModel.dataArr[section]
if homeItem.type == .recommended {
return 12
} else {
return 0
}
}
func interitemSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
let homeItem = self.viewModel.dataArr[section]
if homeItem.type == .recommended {
return 13
} else {
return 0
}
}
}

View File

@ -0,0 +1,113 @@
//
// FASearchViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchViewController: FAViewController {
private lazy var viewModel: FASearchViewModel = FASearchViewModel()
private lazy var returnButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: "Frame 3011"), for: .normal)
button.addAction(UIAction(handler: { [weak self] _ in
self?.handleNavigationBack()
}), for: .touchUpInside)
return button
}()
private lazy var textView: FASearchInputView = {
let view = FASearchInputView()
view.didSearch = { [weak self] text in
guard let self = self else { return }
self.search(text)
}
return view
}()
private lazy var homeView: FASearchHomeView = {
let view = FASearchHomeView()
view.viewModel = viewModel
view.didSearch = { [weak self] text in
self?.textView.text = text
self?.search(text)
}
return view
}()
private lazy var resultView: FASearchResultView = {
let view = FASearchResultView()
view.viewModel = viewModel
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
textView.becomeFirstResponder()
// homeView.isHidden = true
resultView.isHidden = true
fa_setupLayout()
self.viewModel.requestSearchRecommendData(completer: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
private func search(_ text: String) {
if text.isEmpty {
homeView.isHidden = false
resultView.isHidden = true
} else {
homeView.isHidden = true
resultView.isHidden = false
}
resultView.search(text)
self.viewModel.addSearchRecord(text: text)
}
}
extension FASearchViewController {
private func fa_setupLayout() {
view.addSubview(returnButton)
view.addSubview(textView)
view.addSubview(homeView)
view.addSubview(resultView)
returnButton.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview().offset(UIScreen.safeTop)
make.height.equalTo(44)
}
textView.snp.makeConstraints { make in
make.left.equalTo(returnButton.snp.right).offset(10)
make.centerY.equalTo(returnButton)
make.right.equalToSuperview().offset(-16)
}
homeView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalTo(returnButton.snp.bottom)
}
resultView.snp.makeConstraints { make in
make.edges.equalTo(homeView)
}
}
}

View File

@ -0,0 +1,23 @@
//
// FAHomeItem.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
class FAHomeItem: NSObject, Identifiable {
enum ItemType {
case banner
case mustSee
case new
case recommended
}
var type: ItemType?
//FAHomeModuleItem [FAHomeModuleItem]
var data: Any?
var cellHeight: CGFloat = 0
}

View File

@ -0,0 +1,53 @@
//
// FAHomeModuleItem.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import UIKit
import SmartCodable
class FAHomeModuleItem: NSObject, Identifiable, SmartCodable {
required override init() { }
enum ModuleKey: String, SmartCaseDefaultable {
case banner = "home_banner"
case v3_recommand = "home_v3_recommand"
case new_recommand = "new_recommand"
///
case cagetory_recommand = "home_cagetory_recommand"
case week_ranking = "week_ranking"
case week_recommend = "week_highest_recommend"
}
var module_key: ModuleKey?
var title: String?
var list: [FAShortPlayModel] = []
@SmartAny
var data: Any?
func didFinishMapping() {
if let data = data as? [[String : Any]] {
self.list = [FAShortPlayModel].deserialize(from: data) ?? []
} else if let data = data as? [String : Any] {
var dataList: [[String : Any]]?
if let list = data["list"] as? [[String : Any]] {
self.title = data["title"] as? String
dataList = list
} else if let list = data["shortPlayList"] as? [[String : Any]] {
self.title = data["category_name"] as? String
dataList = list
}
if let dataList = dataList {
self.list = [FAShortPlayModel].deserialize(from: dataList) ?? []
}
}
}
}

View File

@ -0,0 +1,43 @@
//
// FAHomeMustSeeContentView.swift
// Fableon
//
// Created by 鸿 on 2025/8/27.
//
import SwiftUI
struct FAHomeMustSeeContentView: View {
@Binding var list: [FAShortPlayModel]
@ObservedObject var viewModel: FAHomeViewModel
var body: some View {
VStack(spacing: 15) {
HStack {
Text("Editor's Picks".localized)
.font(Font.font(size: 16, weight: .medium))
.foregroundStyle(Color(String.color_FFFFFF))
.padding(.leading, 10)
.padding(.top, 12)
Spacer()
}
VStack(spacing: 11) {
ForEach(list.indices, id: \.self) { index in
if index < 2 {
FAHomeMustSeeShortView(model: $list[index], viewModel: viewModel)
}
}
}
.padding(.horizontal, 10)
Spacer(minLength: 0)
}
.frame(height: 310)
.background(Color(String.color_FFFFFF).opacity(0.2))
.cornerRadius(11)
}
}

View File

@ -0,0 +1,80 @@
//
// FAHomeMustSeeShortView.swift
// Fableon
//
// Created by 鸿 on 2025/8/27.
//
import SwiftUI
import Kingfisher
struct FAHomeMustSeeShortView: View {
@Binding var model: FAShortPlayModel
@ObservedObject var viewModel: FAHomeViewModel
var body: some View {
ZStack() {
setupUI()
.onTapGesture {
self.viewModel.pushPlayerDetail(model)
}
}
}
private func setupUI() -> some View {
HStack() {
KFImage(URL(string: model.image_url ?? ""))
.resizable()
.scaledToFill()
.cornerRadius(5)
.clipped()
.frame(width: 76, height: 102)
.padding(.leading, 9)
VStack(alignment: .leading) {
Text(model.name ?? "")
.font(Font.font(size: 14, weight: .medium))
.foregroundStyle(Color(String.color_000000))
.lineLimit(2)
Spacer()
HStack(spacing: 4) {
Image("Frame 2920")
Text("Watch".localized)
.font(Font.font(size: 12, weight: .medium))
.foregroundStyle(Color(String.color_000000))
}
.padding(.trailing, 3)
.frame(maxWidth: .infinity)
.frame(height: 18)
.background(content: {
LinearGradient(colors: [Color(String.color_BEDFFF), Color(String.color_52A2F1)], startPoint: .leading, endPoint: .trailing)
})
.cornerRadius(9)
}
.frame(maxWidth: .infinity)
.padding(.trailing, 9)
.padding(.top, 9)
.padding(.bottom, 9)
}
.frame(height: 120)
.frame(maxWidth: .infinity)
.background(Color(String.color_DDEDFD).opacity(0.4))
.cornerRadius(7)
.overlay {
RoundedRectangle(cornerRadius: 7)
.stroke(Color(String.color_A8DBFF), lineWidth: 1)
}
}
}

View File

@ -0,0 +1,96 @@
//
// FAHomeMustSeeView.swift
// Fableon
//
// Created by 鸿 on 2025/8/27.
//
import SwiftUI
struct FAHomeMustSeeView: View {
@State var selected = 0
// @Binding var selected: Binding<Int> = projectedValue
@ObservedObject var viewModel: FAHomeViewModel
var body: some View {
VStack {
HStack {
Text("Must-see TV series".localized)
.font(Font.font(size: 18, weight: .medium))
.foregroundStyle(Color(String.color_FFFFFF))
.padding(.leading, 16)
Spacer()
}
Spacer(minLength: 12)
HStack {
menuView()
Spacer(minLength: 11)
contentView(index: selected)
}
}
}
private func menuView() -> some View {
let list = viewModel.mustSeeArr
return VStack(spacing: 1) {
ForEach(list.indices, id: \.self) { index in
menuItemView(item: list[index], index: index)
}
}
.padding(.leading, 13)
}
private func menuItemView(item: FAHomeModuleItem, index: Int) -> some View {
let isSelected = index == selected
let textColor = isSelected ? Color(String.color_FFFFFF) : Color(String.color_FFFFFF).opacity(0.8)
return VStack {
HStack {
Text(item.title ?? "")
.font(Font.font(size: 16, weight: .black))
.foregroundStyle(textColor)
.padding(.leading, 8)
Spacer()
}
HStack {
Text("selection".localized)
.font(Font.font(size: 12, weight: .medium))
.foregroundStyle(textColor)
.padding(.leading, 8)
Spacer()
}
HStack {
Image(isSelected ? "Frame 2914" : "Frame 2916")
.padding(.leading, 8)
Spacer()
}
}
.frame(width: 105, height: 72)
.background(Color(String.color_FFFFFF).opacity(0.2))
.cornerRadius(8)
.frame(width: 111, height: 78)
.gradientBorder(colors: isSelected ? [Color(String.color_81CAFF), Color(String.color_20A2FF)] : [Color.clear, Color.clear], lineWidth: 6, cornerRadius: 11, startPoint: .topTrailing, endPoint: .bottomLeading)
.onTapGesture {
self.selected = index
}
}
private func contentView(index: Int) -> some View {
return FAHomeMustSeeContentView(list: $viewModel.mustSeeArr[index].list, viewModel: self.viewModel)
.padding(.trailing, 16)
}
}
//#Preview {
// FAHomeMustSeeView()
//}

View File

@ -0,0 +1,87 @@
//
// FAHomeNewView.swift
// Fableon
//
// Created by 鸿 on 2025/8/27.
//
import SwiftUI
import Kingfisher
import FSPagerView
struct FAHomeNewView: View {
@ObservedObject var viewModel: FAHomeViewModel
@State private var transformer: FSPagerViewTransformer = {
let transformer = FSPagerViewTransformer(type: .overlap)
transformer.minimumScale = 0.9
transformer.minimumAlpha = 1
return transformer
}()
var body: some View {
let list = viewModel.homeNewItem?.list ?? []
VStack(spacing: 15) {
Text("New Releases".localized)
.font(.font(size: 18, weight: .medium))
.foregroundStyle(Color(String.color_FFFFFF))
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
FSPagerSwiftUIView(list) { model in
pageContentView(model)
}
.transformer(
transformer
)
.isLoop(true)
.itemSize(.init(width: 235, height: 235))
.frame(height: 235)
}
}
func pageContentView(_ model: FAShortPlayModel) -> some View {
ZStack {
GeometryReader { proxy in
KFImage(URL(string: model.image_url ?? ""))
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.height)
}
VStack {
Spacer()
HStack(spacing: 4) {
Image("Frame 2921")
Text("Watch".localized)
.font(.font(size: 14, weight: .medium))
.foregroundStyle(Color(String.color_000000))
}
.frame(height: 30)
.frame(maxWidth: .infinity)
.background {
LinearGradient(colors: [Color(String.color_C7DEF5), Color(String.color_52A2F1)], startPoint: .leading, endPoint: .trailing)
}
.clipShape(RoundedRectangle(cornerRadius: 15))
}
.padding(.trailing, 17)
.padding(.leading, 17)
.padding(.bottom, 15)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture {
self.viewModel.pushPlayerDetail(model)
}
}
}

View File

@ -0,0 +1,54 @@
//
// FAHomeRecommendedItemView.swift
// Fableon
//
// Created by 鸿 on 2025/8/28.
//
import SwiftUI
import Kingfisher
struct FAHomeRecommendedItemView: View {
@Binding var model: FAShortPlayModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topTrailing) {
GeometryReader { proxy in
KFImage(URL(string: model.image_url ?? ""))
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
}
if let str = model.category?.first {
ZStack {
Text(str)
.font(.font(size: 12, weight: .medium))
.foregroundStyle(Color(String.color_FFFFFF))
.frame(height: 24)
.padding(.horizontal, 12)
.background(Color(String.color_000000).opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.trailing, 6)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity)
.frame(height: 219)
Text(model.name ?? "")
.font(.font(size: 14, weight: .medium))
.foregroundStyle(Color(String.color_FFFFFF))
.padding(.init(top: 6, leading: 12, bottom: 12, trailing: 12))
}
.background(Color(String.color_333333))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}

View File

@ -0,0 +1,54 @@
//
// FAHomeBannerCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import FSPagerView
class FAHomeBannerCell: FSPagerViewCell {
var model: FAShortPlayModel? {
didSet {
coverImageView.fa_setImage(model?.horizontally_img)
}
}
private lazy var coverImageView: FAImageView = {
let imageView = FAImageView()
imageView.layer.cornerRadius = 12
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.contentView.layer.shadowColor = UIColor.white.cgColor
self.contentView.layer.shadowRadius = 5
self.contentView.layer.shadowOpacity = 0.75
self.contentView.layer.shadowOffset = .init(width: 0, height: 1)
fa_setupLayout()
}
@MainActor required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension FAHomeBannerCell {
private func fa_setupLayout() {
contentView.addSubview(coverImageView)
coverImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}

View File

@ -0,0 +1,89 @@
//
// FAHomeBannerCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import SwiftUI
import FSPagerView
class FAHomeBannerContentCell: UICollectionViewCell {
var moduleItem: FAHomeModuleItem? {
didSet {
pagerView.reloadData()
}
}
weak var viewModel: FAHomeViewModel?
private lazy var pagerView: FSPagerView = {
let transformer = FAPagerViewTransformer(type: .linear)
transformer.minimumAlpha = 1
transformer.minimumScale = 0.9
let view = FSPagerView()
view.itemSize = .init(width: 282, height: 146)
view.transformer = transformer
view.delegate = self
view.dataSource = self
view.isInfinite = true
view.interitemSpacing = 1
view.register(FAHomeBannerCell.self, forCellWithReuseIdentifier: "cell")
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
let subviews = pagerView.subviews.first?.subviews
subviews?.forEach {
if let view = $0 as? UICollectionView{
view.layer.masksToBounds = false
}
}
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension FAHomeBannerContentCell {
private func fa_setupLayout() {
contentView.addSubview(pagerView)
pagerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
//MARK: FSPagerViewDelegate FSPagerViewDataSource
extension FAHomeBannerContentCell: FSPagerViewDelegate, FSPagerViewDataSource {
func pagerView(_ pagerView: FSPagerView, cellForItemAt index: Int) -> FSPagerViewCell {
let cell = pagerView.dequeueReusableCell(withReuseIdentifier: "cell", at: index) as! FAHomeBannerCell
cell.model = moduleItem?.list[index]
return cell
}
func numberOfItems(in pagerView: FSPagerView) -> Int {
return moduleItem?.list.count ?? 0
}
func pagerView(_ pagerView: FSPagerView, didSelectItemAt index: Int) {
let model = moduleItem?.list[index]
self.viewModel?.pushPlayerDetail(model)
}
}

View File

@ -0,0 +1,48 @@
//
// FAHomeMustSeeContentCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import SwiftUI
class FAHomeMustSeeContentCell: UICollectionViewCell {
private var hostingVC: UIHostingController<FAHomeMustSeeView>?
override init(frame: CGRect) {
super.init(frame: frame)
}
func configure(_ viewModel: FAHomeViewModel) {
// self.contentConfiguration = UIHostingConfiguration(content: {
// FAHomeMustSeeView(viewModel: viewModel)
// })
// .margins(.all, 0)
let uiView = FAHomeMustSeeView(viewModel: viewModel)
if let hostingVC = hostingVC {
hostingVC.rootView = uiView
} else {
let hostingVC = UIHostingController(rootView: uiView)
hostingVC.view.backgroundColor = .clear
contentView.addSubview(hostingVC.view)
hostingVC.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
self.hostingVC = hostingVC
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,35 @@
//
// FAHomeNewContentCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import SwiftUI
class FAHomeNewContentCell: UICollectionViewCell {
private var hostingVC: UIHostingController<FAHomeNewView>?
func configure(_ viewModel: FAHomeViewModel) {
// self.contentConfiguration = UIHostingConfiguration(content: {
// FAHomeNewView(viewModel: viewModel)
// })
// .margins(.all, 0)
let uiView = FAHomeNewView(viewModel: viewModel)
if let hostingVC = hostingVC {
hostingVC.rootView = uiView
} else {
let hostingVC = UIHostingController(rootView: uiView)
hostingVC.view.backgroundColor = .clear
contentView.addSubview(hostingVC.view)
hostingVC.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
self.hostingVC = hostingVC
}
}
}

View File

@ -0,0 +1,42 @@
//
// FAHomeRecommendedCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
class FAHomeRecommendedCell: UICollectionViewCell {
var model: FAShortPlayModel? {
didSet {
coverImageView.fa_setImage(model?.image_url)
titleLabel.text = model?.name
if let text = model?.category?.first, !text.isEmpty {
markView.isHidden = false
markLabel.text = text
} else {
markView.isHidden = true
}
}
}
@IBOutlet weak var coverImageView: FAImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var markView: UIView!
@IBOutlet weak var markLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
self.layer.cornerRadius = 8
self.layer.masksToBounds = true
}
}

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FAHomeRecommendedCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="260" height="349"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="260" height="349"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="AeS-tg-K1o" customClass="FAImageView" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="260" height="219"/>
<constraints>
<constraint firstAttribute="height" constant="219" id="FwC-h7-eqn"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lOB-pc-VGc">
<rect key="frame" x="12" y="225" width="36" height="17"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="14"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qIU-LN-B3H">
<rect key="frame" x="199.33333333333334" y="6" width="54.666666666666657" height="24"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Kb9-Mz-sBq">
<rect key="frame" x="12.000000000000002" y="5.0000000000000009" width="30.666666666666671" height="14.333333333333336"/>
<fontDescription key="fontDescription" name="HelveticaNeue-Medium" family="Helvetica Neue" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" name="#000000_0.75"/>
<constraints>
<constraint firstAttribute="height" constant="24" id="BIp-U6-N1h"/>
<constraint firstItem="Kb9-Mz-sBq" firstAttribute="leading" secondItem="qIU-LN-B3H" secondAttribute="leading" constant="12" id="ZHY-Wj-iEk"/>
<constraint firstItem="Kb9-Mz-sBq" firstAttribute="centerY" secondItem="qIU-LN-B3H" secondAttribute="centerY" id="b5T-nu-Z5k"/>
<constraint firstItem="Kb9-Mz-sBq" firstAttribute="centerX" secondItem="qIU-LN-B3H" secondAttribute="centerX" id="nWL-eG-xqM"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="12"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<color key="backgroundColor" name="#333333"/>
<constraints>
<constraint firstItem="lOB-pc-VGc" firstAttribute="leading" secondItem="ZTg-uK-7eu" secondAttribute="leading" constant="12" id="9sz-zS-G6x"/>
<constraint firstItem="qIU-LN-B3H" firstAttribute="trailing" secondItem="AeS-tg-K1o" secondAttribute="trailing" constant="-6" id="NsV-Te-Bu7"/>
<constraint firstItem="AeS-tg-K1o" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="WxT-0p-Elc"/>
<constraint firstItem="AeS-tg-K1o" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="daR-2A-o1r"/>
<constraint firstItem="qIU-LN-B3H" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" constant="6" id="n3p-MC-Uts"/>
<constraint firstItem="lOB-pc-VGc" firstAttribute="top" secondItem="AeS-tg-K1o" secondAttribute="bottom" constant="6" id="s9L-SF-7ko"/>
<constraint firstItem="ZTg-uK-7eu" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="lOB-pc-VGc" secondAttribute="trailing" constant="12" id="tba-OX-t1D"/>
<constraint firstAttribute="trailing" secondItem="AeS-tg-K1o" secondAttribute="trailing" id="u5d-kn-sVr"/>
</constraints>
<size key="customSize" width="260" height="349"/>
<connections>
<outlet property="coverImageView" destination="AeS-tg-K1o" id="PGl-C1-X0a"/>
<outlet property="markLabel" destination="Kb9-Mz-sBq" id="FsI-8Y-r07"/>
<outlet property="markView" destination="qIU-LN-B3H" id="BOY-Lj-gVw"/>
<outlet property="titleLabel" destination="lOB-pc-VGc" id="eaL-Yh-t05"/>
</connections>
<point key="canvasLocation" x="299.23664122137404" y="146.83098591549296"/>
</collectionViewCell>
</objects>
<resources>
<namedColor name="#000000_0.75">
<color red="0.0" green="0.0" blue="0.0" alpha="0.75" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#333333">
<color red="0.20000000000000001" green="0.20000000000000001" blue="0.20000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,17 @@
//
// FAHomeSectionTitleView.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
class FAHomeSectionTitleView: UICollectionReusableView {
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
}

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionReusableView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="U6b-Vx-4bR">
<rect key="frame" x="0.0" y="0.0" width="555" height="123"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Recommended for you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8Zn-D3-Qbm">
<rect key="frame" x="16" y="0.0" width="186.33333333333334" height="21"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="18"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="VXr-Tz-HHm"/>
<constraints>
<constraint firstItem="8Zn-D3-Qbm" firstAttribute="leading" secondItem="U6b-Vx-4bR" secondAttribute="leading" constant="16" id="EqE-jT-c4H"/>
<constraint firstAttribute="top" secondItem="8Zn-D3-Qbm" secondAttribute="top" id="WgU-9Y-14J"/>
</constraints>
<point key="canvasLocation" x="318.32061068702291" y="66.549295774647888"/>
</collectionReusableView>
</objects>
<resources>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,95 @@
//
// FASearchHomeView.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchHomeView: UIView {
weak var viewModel: FASearchViewModel? {
didSet {
viewModel?.addObserver(self, forKeyPath: "recommendData", context: nil)
viewModel?.addObserver(self, forKeyPath: "recordList", context: nil)
self.recommendView.dataArr = self.viewModel?.recommendData ?? []
self.recordView.dataArr = self.viewModel?.recordList ?? []
updateLayout()
}
}
var didSearch: ((_ text: String) -> Void)?
private lazy var stackView: UIStackView = {
let view = UIStackView()
view.spacing = 20
view.axis = .vertical
return view
}()
private lazy var recordView: FASearchRecordView = {
let view = FASearchRecordView()
view.didSearch = { [weak self] text in
self?.didSearch?(text)
}
view.didDelete = { [weak self] in
self?.viewModel?.clearSearchRecord()
}
return view
}()
private lazy var recommendView: FASearchRecommendView = {
let view = FASearchRecommendView()
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
updateLayout()
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "recommendData" {
self.recommendView.dataArr = self.viewModel?.recommendData ?? []
} else if keyPath == "recordList" {
self.recordView.dataArr = self.viewModel?.recordList ?? []
}
updateLayout()
}
func updateLayout() {
stackView.fa_removeAllArrangedSubview()
if self.recordView.dataArr.count > 0 {
stackView.addArrangedSubview(recordView)
}
if self.recommendView.dataArr.count > 0 {
stackView.addArrangedSubview(self.recommendView)
}
}
}
extension FASearchHomeView {
private func fa_setupLayout() {
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalToSuperview().offset(20)
make.bottom.lessThanOrEqualToSuperview()
// make.bottom.equalToSuperview()
}
}
}

View File

@ -0,0 +1,108 @@
//
// FASearchInputView.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchInputView: UIView {
override var intrinsicContentSize: CGSize {
return .init(width: UIScreen.width, height: 32)
}
var didSearch: ((_ text: String) -> Void)?
var text: String? {
get {
return textField.text
}
set {
textField.text = newValue
}
}
private lazy var iconImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "Search"))
imageView.setContentHuggingPriority(.required, for: .horizontal)
imageView.setContentCompressionResistancePriority(.required, for: .horizontal)
return imageView
}()
private lazy var textField: UITextField = {
let textField = UITextField(frame: .zero)
textField.tintColor = UIColor.FFFFFF
textField.delegate = self
textField.returnKeyType = .search
textField.font = .font(ofSize: 12, weight: .medium)
textField.textColor = .FFFFFF
textField.attributedPlaceholder = NSAttributedString(string: "Search".localized, attributes: [
.font : UIFont.font(ofSize: 12, weight: .medium),
.foregroundColor : UIColor.FFFFFF.withAlphaComponent(0.5)
])
return textField
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = 16
self.layer.masksToBounds = true
self.backgroundColor = .FFFFFF_0_25
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@discardableResult
override func becomeFirstResponder() -> Bool {
super.becomeFirstResponder()
return self.textField.becomeFirstResponder()
}
@discardableResult
override func resignFirstResponder() -> Bool {
super.resignFirstResponder()
return self.textField.resignFirstResponder()
}
}
extension FASearchInputView {
private func fa_setupLayout() {
addSubview(iconImageView)
addSubview(textField)
iconImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(18)
}
textField.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.left.equalTo(iconImageView.snp.right).offset(6)
make.right.equalToSuperview().offset(-18)
}
}
}
//MARK: UITextFieldDelegate
extension FASearchInputView: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
if let text = textField.text {
self.didSearch?(text)
}
return true
}
}

View File

@ -0,0 +1,55 @@
//
// FASearchRecommendCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchRecommendCell: UICollectionViewCell {
var model: FAShortPlayModel? {
didSet {
coverImageView.fa_setImage(model?.image_url)
titleLabel.text = model?.name
countLabel.text = "\(model?.watch_total ?? 0)"
epLabel.text = "Ep.##".localizedReplace(text: "\(model?.episode_total ?? 0)")
}
}
var row: Int = 0 {
didSet {
let num = row + 1
numberLabel.text = "\(num)"
switch num {
case 1:
numberLabel.textColor = .F_94_F_7_F
case 2:
numberLabel.textColor = .FE_6_C_05
case 3:
numberLabel.textColor = .F_8_D_01_D
default:
numberLabel.textColor = .FFFFFF.withAlphaComponent(0.8)
}
}
}
@IBOutlet weak var numberLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var coverImageView: FAImageView!
@IBOutlet weak var countLabel: UILabel!
@IBOutlet weak var epLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
}

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FASearchRecommendCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="427" height="110"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="427" height="110"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SP2-Rv-8zV">
<rect key="frame" x="12.666666666666664" y="41.666666666666664" width="11" height="26.999999999999993"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="4ku-8h-G6M" customClass="FAImageView" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="37" y="0.0" width="52" height="110"/>
<constraints>
<constraint firstAttribute="width" constant="52" id="sIY-Wo-Q8H"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="6"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="a1H-XH-Dta">
<rect key="frame" x="97" y="7" width="32" height="15"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mA2-30-PsL">
<rect key="frame" x="382.33333333333331" y="48" width="31.666666666666686" height="14.333333333333336"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Frame 2915" translatesAutoresizingMaskIntoConstraints="NO" id="xLY-pp-ng9">
<rect key="frame" x="368.33333333333331" y="49" width="12" height="12"/>
</imageView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="JDA-rr-yn7">
<rect key="frame" x="97" y="28" width="39.666666666666657" height="11"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XCJ-Au-4ij">
<rect key="frame" x="9.0000000000000018" y="0.66666666666666785" width="21.666666666666671" height="9.6666666666666661"/>
<fontDescription key="fontDescription" type="system" pointSize="8"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" name="#6D6D6D_0.4"/>
<constraints>
<constraint firstItem="XCJ-Au-4ij" firstAttribute="centerY" secondItem="JDA-rr-yn7" secondAttribute="centerY" id="5VP-tP-Y8Q"/>
<constraint firstAttribute="height" constant="11" id="W6s-IK-Ums"/>
<constraint firstItem="XCJ-Au-4ij" firstAttribute="centerX" secondItem="JDA-rr-yn7" secondAttribute="centerX" id="ZFH-uO-qZv"/>
<constraint firstItem="XCJ-Au-4ij" firstAttribute="leading" secondItem="JDA-rr-yn7" secondAttribute="leading" constant="9" id="eMJ-20-GjA"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<real key="value" value="5.5"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="boolean" keyPath="layer.masksToBounds" value="YES"/>
</userDefinedRuntimeAttributes>
</view>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<constraints>
<constraint firstItem="a1H-XH-Dta" firstAttribute="leading" secondItem="4ku-8h-G6M" secondAttribute="trailing" constant="8" id="CWI-yx-YfW"/>
<constraint firstItem="4ku-8h-G6M" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" constant="37" id="DJd-9S-vAe"/>
<constraint firstItem="SP2-Rv-8zV" firstAttribute="centerX" secondItem="ZTg-uK-7eu" secondAttribute="leading" constant="18" id="LNZ-57-juM"/>
<constraint firstAttribute="bottom" secondItem="4ku-8h-G6M" secondAttribute="bottom" id="Luc-T1-KaD"/>
<constraint firstItem="ZTg-uK-7eu" firstAttribute="trailing" secondItem="mA2-30-PsL" secondAttribute="trailing" constant="13" id="Mgk-UZ-DNC"/>
<constraint firstItem="mA2-30-PsL" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="RPU-Dv-KCI"/>
<constraint firstItem="4ku-8h-G6M" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="Yxw-uT-vEd"/>
<constraint firstItem="SP2-Rv-8zV" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="Z4h-SD-e52"/>
<constraint firstItem="ZTg-uK-7eu" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="a1H-XH-Dta" secondAttribute="trailing" constant="90" id="dJ6-D7-bi9"/>
<constraint firstItem="JDA-rr-yn7" firstAttribute="top" secondItem="a1H-XH-Dta" secondAttribute="bottom" constant="6" id="jdM-iM-Lbd"/>
<constraint firstItem="a1H-XH-Dta" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" constant="7" id="kwi-ly-o9u"/>
<constraint firstItem="xLY-pp-ng9" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="tUT-pl-HIL"/>
<constraint firstItem="JDA-rr-yn7" firstAttribute="leading" secondItem="a1H-XH-Dta" secondAttribute="leading" id="upz-Ss-Uhk"/>
<constraint firstItem="mA2-30-PsL" firstAttribute="leading" secondItem="xLY-pp-ng9" secondAttribute="trailing" constant="2" id="xx6-ue-UaT"/>
</constraints>
<size key="customSize" width="427" height="110"/>
<connections>
<outlet property="countLabel" destination="mA2-30-PsL" id="Fzo-V8-t5Z"/>
<outlet property="coverImageView" destination="4ku-8h-G6M" id="llT-4Q-HA2"/>
<outlet property="epLabel" destination="XCJ-Au-4ij" id="Och-Zg-g7E"/>
<outlet property="numberLabel" destination="SP2-Rv-8zV" id="mA4-c8-7XC"/>
<outlet property="titleLabel" destination="a1H-XH-Dta" id="sTz-nT-rBP"/>
</connections>
<point key="canvasLocation" x="426.71755725190837" y="62.676056338028175"/>
</collectionViewCell>
</objects>
<resources>
<image name="Frame 2915" width="12" height="12"/>
<namedColor name="#6D6D6D_0.4">
<color red="0.42745098039215684" green="0.42745098039215684" blue="0.42745098039215684" alpha="0.40000000000000002" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,99 @@
//
// FASearchRecommendView.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchRecommendView: UIView {
override var intrinsicContentSize: CGSize {
return .init(width: UIScreen.width, height: UIScreen.height)
}
var dataArr: [FAShortPlayModel] = [] {
didSet {
self.collectionView.reloadData()
}
}
private lazy var bgView: UIView = {
let view = UIView()
view.backgroundColor = ._5_CA_8_FF_0_2
view.fa_setRoundedCorner(topLeft: 27, topRight: 27, bottomLeft: 0, bottomRight: 0)
return view
}()
private lazy var collectionViewLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.itemSize = .init(width: UIScreen.width - 32, height: 70)
layout.minimumInteritemSpacing = 15
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.keyboardDismissMode = .onDrag
collectionView.contentInset = .init(top: 15, left: 0, bottom: UIScreen.safeBottom + 10, right: 0)
collectionView.register(UINib(nibName: "FASearchRecommendCell", bundle: nil), forCellWithReuseIdentifier: "cell")
return collectionView
}()
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension FASearchRecommendView {
private func fa_setupLayout() {
addSubview(bgView)
addSubview(collectionView)
bgView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview()
make.bottom.equalToSuperview()
make.centerX.equalToSuperview()
}
collectionView.snp.makeConstraints { make in
make.edges.equalTo(bgView)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension FASearchRecommendView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FASearchRecommendCell
cell.row = indexPath.row
cell.model = dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = dataArr[indexPath.row]
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
self.viewController?.navigationController?.pushViewController(vc, animated: true)
}
}

View File

@ -0,0 +1,26 @@
//
// FASearchRecordCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchRecordCell: UICollectionViewCell {
static let TextFont: UIFont = .font(ofSize: 12, weight: .regular)
@IBOutlet weak var textLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
contentView.layer.cornerRadius = 12
contentView.layer.masksToBounds = true
contentView.backgroundColor = .FFFFFF_0_25
textLabel.font = Self.TextFont
}
}

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FASearchRecordCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="160" height="50"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="160" height="50"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="kF0-y1-Mfb">
<rect key="frame" x="64.666666666666671" y="18" width="31" height="14.333333333333336"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<constraints>
<constraint firstItem="kF0-y1-Mfb" firstAttribute="centerX" secondItem="gTV-IL-0wX" secondAttribute="centerX" id="NtY-07-0jo"/>
<constraint firstItem="kF0-y1-Mfb" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="YPF-Or-2JY"/>
</constraints>
<size key="customSize" width="160" height="50"/>
<connections>
<outlet property="textLabel" destination="kF0-y1-Mfb" id="uDt-Vq-Q8O"/>
</connections>
<point key="canvasLocation" x="222.90076335877862" y="41.549295774647888"/>
</collectionViewCell>
</objects>
<resources>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,135 @@
//
// FASearchRecordView.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
import collection_view_layouts
class FASearchRecordView: UIView {
var didSearch: ((_ text: String) -> Void)?
var didDelete: (() -> Void)?
var dataArr: [String] = [] {
didSet {
self.collectionView.reloadData()
}
}
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .medium)
label.textColor = .FFFFFF
label.text = "Historical search".localized
return label
}()
private lazy var deleteButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: "路径 264"), for: .normal)
button.addAction(UIAction(handler: { [weak self] _ in
self?.didDelete?()
}), for: .touchUpInside)
return button
}()
private lazy var collectionViewLayout: TagsLayout = {
let layout = TagsLayout()
layout.delegate = self
layout.contentPadding = ItemsPadding(horizontal: 16, vertical: 0)
layout.cellsPadding = ItemsPadding(horizontal: 12, vertical: 12)
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.addObserver(self, forKeyPath: "contentSize", context: nil)
collectionView.register(UINib(nibName: "FASearchRecordCell", bundle: nil), forCellWithReuseIdentifier: "tagCell")
return collectionView
}()
deinit {
self.collectionView.removeObserver(self, forKeyPath: "contentSize")
}
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
let height = self.collectionView.contentSize.height
debugLog(height)
self.collectionView.snp.updateConstraints { make in
make.height.equalTo(height + 1)
}
}
}
}
extension FASearchRecordView {
private func fa_setupLayout() {
addSubview(titleLabel)
addSubview(deleteButton)
addSubview(collectionView)
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerY.equalTo(deleteButton)
}
deleteButton.snp.makeConstraints { make in
make.right.equalToSuperview().offset(-16)
make.top.equalToSuperview()
}
collectionView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(28)
make.left.equalToSuperview()
make.right.equalToSuperview()
make.bottom.equalToSuperview()
make.height.equalTo(1)
}
}
}
//MARK: UICollectionViewDataSource UICollectionViewDataSource
extension FASearchRecordView: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArr.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "tagCell", for: indexPath) as! FASearchRecordCell
cell.textLabel.text = dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.didSearch?(dataArr[indexPath.row])
}
}
//MARK: LayoutDelegate
extension FASearchRecordView: LayoutDelegate {
func cellSize(indexPath: IndexPath) -> CGSize {
let text = dataArr[indexPath.row]
let size = text.size(FASearchRecordCell.TextFont, .init(width: UIScreen.width, height: 24))
return .init(width: size.width + 24, height: 24)
}
}

View File

@ -0,0 +1,52 @@
//
// FASearchResultCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchResultCell: UICollectionViewCell {
static let CellWidth: CGFloat = floor((UIScreen.width - 32 - 13) / 2)
static let CoverHeight: CGFloat = 219 / 164 * CellWidth
static let TitleFont = UIFont.font(ofSize: 14, weight: .medium)
var model: FAShortPlayModel? {
didSet {
titleLabel.text = model?.name
coverImageView.fa_setImage(model?.image_url)
if let category = model?.category?.first, !category.isEmpty {
categoryView.isHidden = false
categoryLabel.text = category
} else {
categoryView.isHidden = true
}
}
}
@IBOutlet weak var coverImageView: FAImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var categoryView: UIView!
@IBOutlet weak var categoryLabel: UILabel!
@IBOutlet weak var coverHeight: NSLayoutConstraint!
override func awakeFromNib() {
super.awakeFromNib()
titleLabel.font = Self.TitleFont
coverHeight.constant = Self.CoverHeight
}
}

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FASearchResultCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="220" height="329"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="220" height="329"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="jd9-a4-YlS" customClass="FAImageView" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="220" height="219"/>
<constraints>
<constraint firstAttribute="height" constant="219" id="JOV-Y4-g8T"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mFm-6k-c62">
<rect key="frame" x="12.000000000000004" y="225" width="41.333333333333343" height="20.333333333333343"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bci-Nw-k2u">
<rect key="frame" x="158.33333333333334" y="6" width="55.666666666666657" height="24"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lBv-CL-84w">
<rect key="frame" x="12.000000000000002" y="5.0000000000000009" width="31.666666666666671" height="14.333333333333336"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" name="#000000_0.75"/>
<constraints>
<constraint firstItem="lBv-CL-84w" firstAttribute="centerX" secondItem="bci-Nw-k2u" secondAttribute="centerX" id="4Uh-bU-kpV"/>
<constraint firstItem="lBv-CL-84w" firstAttribute="leading" secondItem="bci-Nw-k2u" secondAttribute="leading" constant="12" id="EGX-Dv-EsG"/>
<constraint firstItem="lBv-CL-84w" firstAttribute="centerY" secondItem="bci-Nw-k2u" secondAttribute="centerY" id="IeT-ph-eV1"/>
<constraint firstAttribute="height" constant="24" id="aHJ-CK-ts4"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="12"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="boolean" keyPath="layer.masksToBounds" value="YES"/>
</userDefinedRuntimeAttributes>
</view>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<color key="backgroundColor" name="#333333"/>
<constraints>
<constraint firstItem="ZTg-uK-7eu" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="mFm-6k-c62" secondAttribute="trailing" constant="12" id="6J0-90-aiN"/>
<constraint firstItem="bci-Nw-k2u" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" constant="6" id="7Zr-mb-gWM"/>
<constraint firstAttribute="trailing" secondItem="jd9-a4-YlS" secondAttribute="trailing" id="9f3-El-3XU"/>
<constraint firstAttribute="trailing" secondItem="bci-Nw-k2u" secondAttribute="trailing" constant="6" id="Ioz-EJ-wBm"/>
<constraint firstItem="mFm-6k-c62" firstAttribute="leading" secondItem="ZTg-uK-7eu" secondAttribute="leading" constant="12" id="Ise-os-chG"/>
<constraint firstItem="jd9-a4-YlS" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="eIb-e6-A5Y"/>
<constraint firstItem="mFm-6k-c62" firstAttribute="top" secondItem="jd9-a4-YlS" secondAttribute="bottom" constant="6" id="iC8-gl-1nQ"/>
<constraint firstItem="jd9-a4-YlS" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="jfP-TQ-cdS"/>
</constraints>
<size key="customSize" width="220" height="329"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="8"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="boolean" keyPath="layer.masksToBounds" value="YES"/>
</userDefinedRuntimeAttributes>
<connections>
<outlet property="categoryLabel" destination="lBv-CL-84w" id="Ye2-5P-wYv"/>
<outlet property="categoryView" destination="bci-Nw-k2u" id="IYC-4D-cE6"/>
<outlet property="coverHeight" destination="JOV-Y4-g8T" id="2Dv-rt-jYE"/>
<outlet property="coverImageView" destination="jd9-a4-YlS" id="eWc-3U-ZDz"/>
<outlet property="titleLabel" destination="mFm-6k-c62" id="fWp-yq-Mqw"/>
</connections>
<point key="canvasLocation" x="267.17557251908397" y="139.08450704225353"/>
</collectionViewCell>
</objects>
<resources>
<namedColor name="#000000_0.75">
<color red="0.0" green="0.0" blue="0.0" alpha="0.75" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#333333">
<color red="0.20000000000000001" green="0.20000000000000001" blue="0.20000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,113 @@
//
// FASearchResultView.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchResultView: UIView {
weak var viewModel: FASearchViewModel?
var dataArr: [FAShortPlayModel] = []
private lazy var collectionViewLayout: FAWaterfallFlowLayout = {
let layout = FAWaterfallFlowLayout()
layout.delegate = self
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.ly_emptyView = FAEmpty.fa_emptyView(image: UIImage(named: "__question"), title: "empty_title_01".localized)
collectionView.register(UINib(nibName: "FASearchResultCell", bundle: nil), forCellWithReuseIdentifier: "cell")
return collectionView
}()
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func search(_ text: String) {
if text.isEmpty {
self.dataArr.removeAll()
self.collectionView.reloadData()
return
}
FAAPI.requestSearch(text: text) { [weak self] list in
guard let self = self else { return }
guard let list = list else { return }
self.dataArr = list
self.collectionView.reloadData()
}
}
}
extension FASearchResultView {
private func fa_setupLayout() {
addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(20)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension FASearchResultView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FASearchResultCell
cell.model = dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = dataArr[indexPath.row]
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
self.viewController?.navigationController?.pushViewController(vc, animated: true)
}
}
//MARK: FAWaterfallMutiSectionDelegate
extension FASearchResultView: FAWaterfallMutiSectionDelegate {
func heightForRowAtIndexPath(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, indexPath: IndexPath, itemWidth: CGFloat) -> CGFloat {
let text = dataArr[indexPath.row].name ?? ""
return FASearchResultCell.CoverHeight + text.size(FASearchResultCell.TitleFont, .init(width: FASearchResultCell.CellWidth - 24, height: CGFloat(MAXFLOAT))).height + 18
}
func interitemSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
return 13
}
func lineSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
return 12
}
func insetForSection(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> UIEdgeInsets {
return .init(top: 0, left: 16, bottom: 0, right: 16)
}
}

View File

@ -0,0 +1,115 @@
//
// FAHomeViewModel.swift
// Fableon
//
// Created by 鸿 on 2025/8/26.
//
import SwiftUI
@MainActor
class FAHomeViewModel: ObservableObject {
@Published var dataArr: [FAHomeItem] = []
@Published var bannerItem: FAHomeModuleItem?
@Published var mustSeeArr: [FAHomeModuleItem] = []
@Published var homeNewItem: FAHomeModuleItem?
@Published var recommendedItem: FAHomeModuleItem?
func requestHomeData(completer: (() -> Void)?) {
FAAPI.requestHomeModulesData { [weak self] list in
guard let self = self else { return }
guard let list = list else {
completer?()
return
}
self.dataArr.removeAll()
var popularItem: FAHomeModuleItem?
var rankingsItem: FAHomeModuleItem?
var genresItem: FAHomeModuleItem?
var newItem: FAHomeModuleItem?
list.forEach {
if $0.module_key == .banner {
self.bannerItem = $0
} else if $0.module_key == .v3_recommand {
$0.title = "Popular".localized
popularItem = $0
} else if $0.module_key == .week_ranking {
$0.title = "Rankings".localized
rankingsItem = $0
} else if $0.module_key == .cagetory_recommand, genresItem == nil {
$0.title = "Genres".localized
genresItem = $0
} else if $0.module_key == .new_recommand {
$0.title = "New".localized
newItem = $0
self.homeNewItem = $0
} else if $0.module_key == .week_recommend {
self.recommendedItem = $0
}
}
var mustAee: [FAHomeModuleItem] = []
if let item = popularItem {
mustAee.append(item)
}
if let item = rankingsItem {
mustAee.append(item)
}
if let item = genresItem {
mustAee.append(item)
}
if let item = newItem {
mustAee.append(item)
}
self.mustSeeArr = mustAee
if let item = self.bannerItem {
let homeItem = FAHomeItem()
homeItem.type = .banner
homeItem.data = item
homeItem.cellHeight = 150
self.dataArr.append(homeItem)
}
if self.mustSeeArr.count > 0 {
let homeItem = FAHomeItem()
homeItem.type = .mustSee
homeItem.data = self.mustSeeArr
homeItem.cellHeight = 350
self.dataArr.append(homeItem)
}
if let item = self.homeNewItem {
let homeItem = FAHomeItem()
homeItem.type = .new
homeItem.data = item
homeItem.cellHeight = 272
self.dataArr.append(homeItem)
}
if let item = self.recommendedItem {
let homeItem = FAHomeItem()
homeItem.type = .recommended
homeItem.data = item
self.dataArr.append(homeItem)
}
completer?()
}
}
}
extension FAHomeViewModel {
func pushPlayerDetail(_ model: FAShortPlayModel?) {
guard let model = model else { return }
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
FATool.topViewController?.navigationController?.pushViewController(vc, animated: true)
}
}

View File

@ -0,0 +1,57 @@
//
// FASearchViewModel.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
class FASearchViewModel: NSObject {
static let searchRecordUserDefaultKey = "FASearchViewModel.searchRecordUserDefaultKey"
@objc dynamic private(set) var recordList: [String] = (UserDefaults.standard.object(forKey: FASearchViewModel.searchRecordUserDefaultKey) as? [String]) ?? []
@objc dynamic private(set) lazy var recommendData: [FAShortPlayModel] = []
func addSearchRecord(text: String) {
guard !text.isEmpty else { return }
var list = recordList
for (index, value) in list.enumerated() {
if value == text {
list.remove(at: index)
break
}
}
list.insert(text, at: 0)
if list.count > 10 {
list.removeLast()
}
recordList = list
UserDefaults.standard.set(list, forKey: FASearchViewModel.searchRecordUserDefaultKey)
}
func clearSearchRecord() {
recordList.removeAll()
UserDefaults.standard.set(recordList, forKey: FASearchViewModel.searchRecordUserDefaultKey)
}
///
func requestSearchRecommendData(completer: (() -> Void)?) {
FAAPI.requestHotSearchData { [weak self] list in
guard let self = self else { return }
if let list = list {
self.recommendData = list
}
}
}
}

View File

@ -0,0 +1,106 @@
//
// FAAboutViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/15.
//
import UIKit
class FAAboutViewController: FAViewController {
private lazy var dataArr: [FAMeItemModel] = [
FAMeItemModel(type: .privacyPolicy, name: "Privacy Policy".localized),
FAMeItemModel(type: .userAgreement, name: "User Agreement".localized),
FAMeItemModel(type: .visitWebsite, name: "Visit Website".localized),
]
private lazy var tableView: FATableView = {
let tableView = FATableView(frame: .zero, style: .plain)
tableView.tableHeaderView = self.headerView
tableView.delegate = self
tableView.dataSource = self
tableView.separatorInset = .init(top: 0, left: 32, bottom: 0, right: 32)
tableView.register(UINib(nibName: "FAAboutCell", bundle: nil), forCellReuseIdentifier: "cell")
return tableView
}()
private lazy var headerView: FAAboutHeaderView = {
let view = FAAboutHeaderView(frame: .init(x: 0, y: 0, width: UIScreen.width, height: 186))
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
self.title = "About".localized
fa_setupLayout()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.fa_setNavigationStyle()
}
}
extension FAAboutViewController {
private func fa_setupLayout() {
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(UIScreen.navBarHeight)
}
}
}
//MARK: UITableViewDelegate UITableViewDataSource
extension FAAboutViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataArr.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! FAAboutCell
cell.item = dataArr[indexPath.row]
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = dataArr[indexPath.row]
var urlStr: String? = nil
switch item.type {
case .privacyPolicy:
urlStr = FAWebBaseURL + "/private"
case .userAgreement:
urlStr = FAWebBaseURL + "/user_policy"
case .visitWebsite:
if let url = URL(string: FAWebBaseURL) {
UIApplication.shared.open(url)
}
default:
break
}
if let urlStr = urlStr {
let vc = FABaseWebViewController()
vc.webUrl = urlStr
self.navigationController?.pushViewController(vc, animated: true)
}
}
}

View File

@ -0,0 +1,30 @@
//
// FAFeedbackViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
class FAFeedbackViewController: FAAppWebViewController {
override func viewDidLoad() {
self.webUrl = kFAFeedBackHomeWebUrl
super.viewDidLoad()
}
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
}
*/
}

View File

@ -0,0 +1,188 @@
//
// FAMeViewController.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
import YYText
class FAMeViewController: FAViewController {
private lazy var dataArr: [FAMeItemModel] = {
let arr = [
// FAMeItemModel(type: .feedback, name: "Feedback".localized, icon: UIImage(named: "icon_feedback")),
FAMeItemModel(type: .about, name: "About".localized, icon: UIImage(named: "icon_about")),
// FAMeItemModel(type: .setting, name: "Setting".localized, icon: UIImage(named: "icon_setting"))
FAMeItemModel(type: .privacyPolicy, name: "Privacy Policy".localized, icon: UIImage(named: "icon_privacy")),
FAMeItemModel(type: .userAgreement, name: "User Agreement".localized, icon: UIImage(named: "icon_user")),
FAMeItemModel(type: .visitWebsite, name: "Visit Website".localized, icon: UIImage(named: "icon_visit")),
]
return arr
}()
private lazy var scrollView: FAScrollView = {
let scrollView = FAScrollView()
return scrollView
}()
private lazy var headerView: FAMeHeaderView = {
let view = FAMeHeaderView()
view.userInfo = FALogin.manager.userInfo
return view
}()
private lazy var contentView: UIView = {
let view = UIView()
view.backgroundColor = ._4_D_4_A_4_A.withAlphaComponent(0.5)
view.fa_setRoundedCorner(topLeft: 30, topRight: 30, bottomLeft: 0, bottomRight: 0)
return view
}()
private lazy var tableView: FATableView = {
let tableView = FATableView(frame: .zero, style: .plain)
tableView.delegate = self
tableView.dataSource = self
tableView.rowHeight = 56
tableView.separatorInset = .init(top: 0, left: 32, bottom: 0, right: 32)
tableView.register(UINib(nibName: "FAMeCell", bundle: nil), forCellReuseIdentifier: "cell")
tableView.addObserver(self, forKeyPath: "contentSize", context: nil)
return tableView
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(userInfoUpdateNotification), name: FALogin.userInfoUpdateNotification, object: nil)
fa_setupLayout()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
FALogin.manager.requestUserInfo(completer: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
self.updateLayout()
}
}
@objc private func userInfoUpdateNotification() {
headerView.userInfo = FALogin.manager.userInfo
}
}
extension FAMeViewController {
private func fa_setupLayout() {
view.addSubview(scrollView)
scrollView.addSubview(headerView)
scrollView.addSubview(contentView)
contentView.addSubview(tableView)
scrollView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(UIScreen.safeTop)
}
headerView.snp.makeConstraints { make in
make.left.centerX.equalToSuperview()
make.top.equalToSuperview().offset(40)
}
contentView.snp.makeConstraints { make in
make.left.centerX.equalToSuperview()
make.top.equalTo(headerView.snp.bottom).offset(24)
make.bottom.equalToSuperview()
make.height.equalTo(UIScreen.height - UIScreen.tabBarHeight - UIScreen.safeTop)
}
tableView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(19)
}
}
private func updateLayout() {
let maxHeight = UIScreen.height - UIScreen.tabBarHeight - UIScreen.safeTop
let minHeight = UIScreen.height - UIScreen.tabBarHeight - UIScreen.safeTop - self.headerView.height - 40 - 24
var height = self.tableView.contentSize.height
if height > maxHeight {
height = maxHeight
}
if height < minHeight {
height = minHeight
}
contentView.snp.updateConstraints { make in
make.height.equalTo(height)
}
}
}
//MARK: UITableViewDelegate, UITableViewDataSource
extension FAMeViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! FAMeCell
cell.item = dataArr[indexPath.row]
return cell
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataArr.count
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = dataArr[indexPath.row]
switch item.type {
case .about:
let vc = FAAboutViewController()
self.navigationController?.pushViewController(vc, animated: true)
case .setting:
let vc = FASettingViewController()
self.navigationController?.pushViewController(vc, animated: true)
case .feedback:
let vc = FAFeedbackViewController()
self.navigationController?.pushViewController(vc, animated: true)
case .privacyPolicy:
let vc = FABaseWebViewController()
vc.webUrl = FAWebBaseURL + "/private"
self.navigationController?.pushViewController(vc, animated: true)
case .userAgreement:
let vc = FABaseWebViewController()
vc.webUrl = FAWebBaseURL + "/user_policy"
self.navigationController?.pushViewController(vc, animated: true)
case .visitWebsite:
if let url = URL(string: FAWebBaseURL) {
UIApplication.shared.open(url)
}
default:
break
}
}
}

View File

@ -0,0 +1,26 @@
//
// FASettingViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/16.
//
import UIKit
class FASettingViewController: FAViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.title = "Settings".localized
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.fa_setNavigationStyle()
}
}

View File

@ -0,0 +1,25 @@
//
// FAMeItemModel.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
struct FAMeItemModel {
enum ItemType {
case feedback
case about
case setting
case privacyPolicy
case userAgreement
case visitWebsite
}
var type: ItemType?
var name: String?
var icon: UIImage?
}

View File

@ -0,0 +1,33 @@
//
// FAAboutCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/15.
//
import UIKit
class FAAboutCell: FATableViewCell {
var item: FAMeItemModel? {
didSet {
titleLabel.text = item?.name
}
}
@IBOutlet weak var titleLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="FAAboutCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="V1G-kT-efB">
<rect key="frame" x="32" y="15" width="32.333333333333343" height="14.333333333333336"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="V1G-kT-efB" secondAttribute="trailing" constant="32" id="BIU-ey-6e6"/>
<constraint firstItem="V1G-kT-efB" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="cOi-y9-XWL"/>
<constraint firstItem="V1G-kT-efB" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="32" id="iB3-t3-yb3"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="titleLabel" destination="V1G-kT-efB" id="0yb-Q2-5y3"/>
</connections>
<point key="canvasLocation" x="139" y="42"/>
</tableViewCell>
</objects>
<resources>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,68 @@
//
// FAAboutHeaderView.swift
// Fableon
//
// Created by 鸿 on 2025/10/15.
//
import UIKit
class FAAboutHeaderView: UIView {
private lazy var appLogoView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "logo_image_01"))
imageView.layer.cornerRadius = 8
imageView.layer.masksToBounds = true
return imageView
}()
private lazy var nameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 18, weight: .bold)
label.textColor = .FFFFFF
label.text = kFAAPPName
return label
}()
private lazy var versionLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = ._999999
label.text = "Version \(kFAAPPVersion)"
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension FAAboutHeaderView {
private func fa_setupLayout() {
addSubview(appLogoView)
addSubview(nameLabel)
addSubview(versionLabel)
appLogoView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(30)
make.width.height.equalTo(84)
}
nameLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalTo(appLogoView.snp.bottom).offset(13)
}
versionLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalTo(nameLabel.snp.bottom).offset(6)
}
}
}

View File

@ -0,0 +1,36 @@
//
// FAMeCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FAMeCell: FATableViewCell {
var item: FAMeItemModel? {
didSet {
iconImageView.image = item?.icon
nameLabel.text = item?.name
}
}
@IBOutlet weak var iconImageView: UIImageView!
@IBOutlet weak var nameLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="67" id="KGk-i7-Jjw" customClass="FAMeCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="372" height="67"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="372" height="67"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="oa5-QI-16V">
<rect key="frame" x="32" y="23.666666666666671" width="20" height="20"/>
<constraints>
<constraint firstAttribute="height" constant="20" id="6o1-we-ZGi"/>
<constraint firstAttribute="width" constant="20" id="qOw-IG-TUe"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="DdC-ud-0R6">
<rect key="frame" x="64" y="25" width="36" height="17"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="14"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Frame 3015" translatesAutoresizingMaskIntoConstraints="NO" id="VZl-wc-TsY">
<rect key="frame" x="330" y="28.666666666666671" width="10" height="10"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="DdC-ud-0R6" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="AHI-cq-K4b"/>
<constraint firstItem="oa5-QI-16V" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="32" id="HgV-7h-PWs"/>
<constraint firstItem="DdC-ud-0R6" firstAttribute="leading" secondItem="oa5-QI-16V" secondAttribute="trailing" constant="12" id="JNq-Cy-RPf"/>
<constraint firstItem="oa5-QI-16V" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="OxB-Y8-ihy"/>
<constraint firstItem="VZl-wc-TsY" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="dcK-P6-Acp"/>
<constraint firstAttribute="trailing" secondItem="VZl-wc-TsY" secondAttribute="trailing" constant="32" id="juM-rh-i2t"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="iconImageView" destination="oa5-QI-16V" id="65o-J4-4Pa"/>
<outlet property="nameLabel" destination="DdC-ud-0R6" id="yCn-Yd-29k"/>
</connections>
<point key="canvasLocation" x="178.62595419847327" y="81.338028169014095"/>
</tableViewCell>
</objects>
<resources>
<image name="Frame 3015" width="10" height="10"/>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,78 @@
//
// FAMeHeaderView.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FAMeHeaderView: UIView {
var userInfo: FAUserInfo? {
didSet {
avatarImageView.fa_setImage(userInfo?.avator)
userNameLabel.text = userInfo?.getNickName()
idLabel.text = "ID\(userInfo?.customer_id ?? "")"
}
}
private lazy var avatarImageView: FAImageView = {
let imageView = FAImageView()
imageView.layer.cornerRadius = 33
return imageView
}()
private lazy var userNameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .medium)
label.textColor = .FFFFFF
return label
}()
private lazy var idLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .FFFFFF.withAlphaComponent(0.5)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension FAMeHeaderView {
private func fa_setupLayout() {
addSubview(avatarImageView)
addSubview(userNameLabel)
addSubview(idLabel)
avatarImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview()
make.width.height.equalTo(66)
make.bottom.equalToSuperview()
}
userNameLabel.snp.makeConstraints { make in
make.top.equalTo(avatarImageView).offset(15)
make.left.equalTo(avatarImageView.snp.right).offset(12)
make.right.lessThanOrEqualToSuperview().offset(-16)
}
idLabel.snp.makeConstraints { make in
make.left.equalTo(userNameLabel)
make.top.equalTo(userNameLabel.snp.bottom).offset(2)
}
}
}

View File

@ -0,0 +1,193 @@
//
// FACollectViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/9.
//
import UIKit
import SnapKit
class FACollectViewController: FAViewController {
private lazy var page = 1
private lazy var dataArr: [FAShortPlayModel] = []
private lazy var fa_isEditing = false {
didSet {
updateBarButton()
self.collectionView.reloadData()
}
}
private lazy var collectionViewLayout: UICollectionViewFlowLayout = {
let width = floor((UIScreen.width - 16 - 32) / 3)
let height = 145 / 109 * width
let layout = UICollectionViewFlowLayout()
layout.itemSize = .init(width: width, height: height + 59)
layout.minimumLineSpacing = 12
layout.minimumInteritemSpacing = 8
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: self.collectionViewLayout)
collectionView.contentInset = .init(top: 20, left: 0, bottom: 10, right: 0)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.ly_emptyView = FAEmpty.fa_emptyView(image: UIImage(named: "__shop-72"), title: "empty_title_02".localized)
collectionView.register(UINib(nibName: "FACollectCell", bundle: nil), forCellWithReuseIdentifier: "cell")
collectionView.fa_addRefreshHeader(insetTop: collectionView.contentInset.top) { [weak self] in
guard let self = self else { return }
self.handleHeaderRefresh(nil)
}
collectionView.fa_addRefreshFooter(insetBottom: 0) { [weak self] in
self?.handleFooterRefresh(nil)
}
return collectionView
}()
private lazy var editBarButton: UIBarButtonItem = {
let item = UIBarButtonItem(image: UIImage(named: "编辑_icon"), style: .plain, target: self, action: #selector(handleEditButton))
return item
}()
private lazy var historyButton: UIBarButtonItem = {
let item = UIBarButtonItem(image: UIImage(named: "历史记录_icon"), style: .plain, target: self, action: #selector(handleHistoryButton))
return item
}()
private lazy var spaceButton: UIBarButtonItem = {
let item = UIBarButtonItem.fixedSpace(0)
return item
}()
override func viewDidLoad() {
super.viewDidLoad()
self.edgesForExtendedLayout = .top
self.title = "Collect".localized
self.navigationItem.rightBarButtonItems = [historyButton, spaceButton, editBarButton]
fa_setupLayout()
requestDataArr(page: 1, completer: nil)
updateBarButton()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.fa_isEditing = false
}
private func updateBarButton() {
if fa_isEditing {
editBarButton.image = UIImage(named: "done")
} else {
editBarButton.image = UIImage(named: "编辑_icon")
}
}
@objc private func handleEditButton() {
fa_isEditing = !fa_isEditing
}
@objc private func handleHistoryButton() {
let vc = FAHistoryViewController()
self.navigationController?.pushViewController(vc, animated: true)
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
self.requestDataArr(page: 1) { [weak self] in
self?.collectionView.fa_endHeaderRefreshing()
}
}
override func handleFooterRefresh(_ completer: (() -> Void)?) {
self.requestDataArr(page: self.page + 1) { [weak self] in
self?.collectionView.fa_endFooterRefreshing()
}
}
}
extension FACollectViewController {
private func fa_setupLayout() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(UIScreen.navBarHeight)
}
}
}
//MARK: UICollectionViewDataSource UICollectionViewDelegate
extension FACollectViewController: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FACollectCell
cell.model = self.dataArr[indexPath.row]
cell.isEditing = fa_isEditing
cell.clickDeleteButton = { [weak self] cell in
guard let self = self else { return }
guard let indexPath = self.collectionView.indexPath(for: cell) else { return }
guard let shortPlayId = cell.model?.short_play_id else { return }
FAAPI.requestShortCollect(isCollect: false, shortPlayId: shortPlayId, videoId: cell.model?.short_play_video_id) { [weak self] in
guard let self = self else { return }
self.dataArr.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
}
}
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = self.dataArr[indexPath.row]
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
FATool.topViewController?.navigationController?.pushViewController(vc, animated: true)
}
}
extension FACollectViewController {
private func requestDataArr(page: Int, completer: (() -> Void)?) {
FAAPI.requestCollectList(page: page) { [weak self] listModel in
guard let self = self else { return }
if let list = listModel?.list {
if page == 1 {
self.dataArr.removeAll()
}
self.dataArr += list
self.collectionView.reloadData()
self.page = page
}
completer?()
self.collectionView.fa_updateNoMoreDataState(listModel?.hasNextPage)
}
}
}

View File

@ -0,0 +1,126 @@
//
// FAHistoryViewController.swift
// Fableon
//
// Created by 鸿 on 2025/10/9.
//
import UIKit
class FAHistoryViewController: FAViewController {
private lazy var page = 1
private lazy var dataArr: [FAShortPlayModel] = []
private lazy var collectionViewLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 12
layout.itemSize = .init(width: UIScreen.width - 32, height: 115)
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.ly_emptyView = FAEmpty.fa_emptyView(image: UIImage(named: "__shop-72"), title: "empty_title_02".localized)
collectionView.contentInset = .init(top: 20, left: 0, bottom: UIScreen.safeBottom + 10, right: 0)
collectionView.register(UINib(nibName: "FAHistoryCell", bundle: nil), forCellWithReuseIdentifier: "cell")
collectionView.fa_addRefreshHeader(insetTop: collectionView.contentInset.top) { [weak self] in
guard let self = self else { return }
self.handleHeaderRefresh(nil)
}
collectionView.fa_addRefreshFooter(insetBottom: 0) { [weak self] in
self?.handleFooterRefresh(nil)
}
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
self.title = "History".localized
self.edgesForExtendedLayout = .top
fa_setupLayout()
self.requestDataArr(page: 1, completer: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
fa_setNavigationStyle()
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
self.requestDataArr(page: 1) { [weak self] in
self?.collectionView.fa_endHeaderRefreshing()
}
}
override func handleFooterRefresh(_ completer: (() -> Void)?) {
self.requestDataArr(page: self.page + 1) { [weak self] in
self?.collectionView.fa_endFooterRefreshing()
}
}
}
extension FAHistoryViewController {
private func fa_setupLayout() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(UIScreen.navBarHeight)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension FAHistoryViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FAHistoryCell
cell.model = dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = dataArr[indexPath.row]
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
self.navigationController?.pushViewController(vc, animated: true)
}
}
extension FAHistoryViewController {
private func requestDataArr(page: Int, completer: (() -> Void)?) {
FAAPI.requestPlayHistorys(page: page) { [weak self] listModel in
guard let self = self else { return }
if let list = listModel?.list {
if page == 1 {
self.dataArr.removeAll()
}
self.dataArr += list
self.page = page
self.collectionView.reloadData()
}
completer?()
self.collectionView.fa_updateNoMoreDataState(listModel?.hasNextPage)
}
}
}

View File

@ -0,0 +1,51 @@
//
// FACollectCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/9.
//
import UIKit
class FACollectCell: UICollectionViewCell {
var clickDeleteButton: ((_ cell: FACollectCell) -> Void)?
var model: FAShortPlayModel? {
didSet {
nameLabel.text = model?.name
coverImageView.fa_setImage(model?.image_url)
epLabel.text = "Ep.##".localizedReplace(text: "\(model?.episode_total ?? 0)")
}
}
var isEditing: Bool = true {
didSet {
deleteButton.isHidden = !isEditing
}
}
@IBOutlet weak var coverImageView: FAImageView!
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var epLabel: UILabel!
@IBOutlet weak var deleteButton: UIButton!
override func awakeFromNib() {
super.awakeFromNib()
self.deleteButton.layer.borderWidth = 1
self.deleteButton.layer.borderColor = UIColor.FFFFFF_0_25.cgColor
}
@IBAction func handleDeleteButton(_ sender: Any) {
self.clickDeleteButton?(self)
}
}

View File

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FACollectCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="236" height="329"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="236" height="329"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="ft8-51-pRf" customClass="FAImageView" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="236" height="270"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="6"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3hA-MZ-UZy">
<rect key="frame" x="0.0" y="277" width="31" height="14.333333333333314"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YRp-WJ-M8G">
<rect key="frame" x="0.0" y="294.33333333333331" width="26.333333333333332" height="12"/>
<fontDescription key="fontDescription" type="system" pointSize="10"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jTC-5t-LEB">
<rect key="frame" x="0.0" y="0.0" width="236" height="270"/>
<color key="backgroundColor" name="#000000_0.75"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" image="删除"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="6"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="boolean" keyPath="layer.masksToBounds" value="YES"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="handleDeleteButton:" destination="gTV-IL-0wX" eventType="touchUpInside" id="qhI-tk-v9r"/>
</connections>
</button>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<constraints>
<constraint firstItem="jTC-5t-LEB" firstAttribute="trailing" secondItem="ft8-51-pRf" secondAttribute="trailing" id="4mC-59-ddf"/>
<constraint firstItem="YRp-WJ-M8G" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="KxM-Ls-Nc8"/>
<constraint firstItem="jTC-5t-LEB" firstAttribute="bottom" secondItem="ft8-51-pRf" secondAttribute="bottom" id="LcN-MM-5FL"/>
<constraint firstItem="jTC-5t-LEB" firstAttribute="leading" secondItem="ft8-51-pRf" secondAttribute="leading" id="SDp-b4-9AN"/>
<constraint firstItem="ft8-51-pRf" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="WFp-eV-4ML"/>
<constraint firstAttribute="bottom" secondItem="ft8-51-pRf" secondAttribute="bottom" constant="59" id="YFx-jl-R0t"/>
<constraint firstItem="ft8-51-pRf" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="crW-Gh-emI"/>
<constraint firstItem="3hA-MZ-UZy" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="hu5-S1-g5i"/>
<constraint firstAttribute="trailing" secondItem="ft8-51-pRf" secondAttribute="trailing" id="i2G-DF-5DP"/>
<constraint firstItem="3hA-MZ-UZy" firstAttribute="top" secondItem="ft8-51-pRf" secondAttribute="bottom" constant="7" id="rAf-Lw-Ija"/>
<constraint firstItem="jTC-5t-LEB" firstAttribute="top" secondItem="ft8-51-pRf" secondAttribute="top" id="shQ-wC-7bx"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="3hA-MZ-UZy" secondAttribute="trailing" id="vN9-gX-ck4"/>
<constraint firstItem="YRp-WJ-M8G" firstAttribute="top" secondItem="3hA-MZ-UZy" secondAttribute="bottom" constant="3" id="yOT-VM-d0c"/>
</constraints>
<size key="customSize" width="236" height="329"/>
<connections>
<outlet property="coverImageView" destination="ft8-51-pRf" id="MLR-b4-bcQ"/>
<outlet property="deleteButton" destination="jTC-5t-LEB" id="0GS-YK-3Ag"/>
<outlet property="epLabel" destination="YRp-WJ-M8G" id="Isd-pp-hUI"/>
<outlet property="nameLabel" destination="3hA-MZ-UZy" id="62J-96-16m"/>
</connections>
<point key="canvasLocation" x="279.38931297709922" y="139.78873239436621"/>
</collectionViewCell>
</objects>
<resources>
<image name="删除" width="26" height="26"/>
<namedColor name="#000000_0.75">
<color red="0.0" green="0.0" blue="0.0" alpha="0.75" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,35 @@
//
// FAHistoryCell.swift
// Fableon
//
// Created by 鸿 on 2025/10/13.
//
import UIKit
class FAHistoryCell: UICollectionViewCell {
var model: FAShortPlayModel? {
didSet {
coverImageView.fa_setImage(model?.image_url)
nameLabel.text = model?.name
epLabel.text = "Ep.##".localizedReplace(text: model?.current_episode) + "/" + "Ep.##".localizedReplace(text: "\(model?.episode_total ?? 0)")
}
}
@IBOutlet weak var coverImageView: FAImageView!
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var epLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
self.contentView.layer.cornerRadius = 13
self.contentView.layer.masksToBounds = true
self.contentView.backgroundColor = ._5_CA_8_FF_0_2
}
}

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FAHistoryCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="338" height="130"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="338" height="130"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="bzV-5Y-YHI" customClass="FAImageView" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="12" y="12" width="68" height="106"/>
<constraints>
<constraint firstAttribute="width" constant="68" id="MMl-Ix-A0Y"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="5"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Oh0-jp-gJm">
<rect key="frame" x="92" y="37.666666666666664" width="36" height="17"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="14"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YAk-yJ-afV">
<rect key="frame" x="92" y="62.666666666666657" width="26.333333333333329" height="12"/>
<fontDescription key="fontDescription" type="system" pointSize="10"/>
<color key="textColor" name="#FFFFFF"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Frame 3012" translatesAutoresizingMaskIntoConstraints="NO" id="xGT-Pb-RnF">
<rect key="frame" x="306" y="55" width="20" height="20"/>
</imageView>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<constraints>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Oh0-jp-gJm" secondAttribute="trailing" constant="90" id="8SF-0D-cdU"/>
<constraint firstItem="Oh0-jp-gJm" firstAttribute="leading" secondItem="bzV-5Y-YHI" secondAttribute="trailing" constant="12" id="HFd-H7-RQU"/>
<constraint firstItem="YAk-yJ-afV" firstAttribute="leading" secondItem="Oh0-jp-gJm" secondAttribute="leading" id="K3I-e4-hbk"/>
<constraint firstItem="bzV-5Y-YHI" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" constant="12" id="PZV-aq-N5S"/>
<constraint firstItem="bzV-5Y-YHI" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" constant="12" id="Ssy-Q9-jip"/>
<constraint firstItem="xGT-Pb-RnF" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="TzV-OM-veN"/>
<constraint firstAttribute="bottom" secondItem="bzV-5Y-YHI" secondAttribute="bottom" constant="12" id="Wgj-z6-4a0"/>
<constraint firstItem="Oh0-jp-gJm" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="top" constant="46" id="kgG-BH-49a"/>
<constraint firstItem="xGT-Pb-RnF" firstAttribute="trailing" secondItem="ZTg-uK-7eu" secondAttribute="trailing" constant="-12" id="nL7-h7-dTk"/>
<constraint firstItem="YAk-yJ-afV" firstAttribute="top" secondItem="Oh0-jp-gJm" secondAttribute="bottom" constant="8" id="q73-qQ-lSH"/>
</constraints>
<size key="customSize" width="338" height="130"/>
<connections>
<outlet property="coverImageView" destination="bzV-5Y-YHI" id="3QE-jt-Kcw"/>
<outlet property="epLabel" destination="YAk-yJ-afV" id="Lyz-Jh-61u"/>
<outlet property="nameLabel" destination="Oh0-jp-gJm" id="mwy-Eq-RzR"/>
</connections>
<point key="canvasLocation" x="358.77862595419845" y="69.718309859154928"/>
</collectionViewCell>
</objects>
<resources>
<image name="Frame 3012" width="20" height="20"/>
<namedColor name="#FFFFFF">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -0,0 +1,23 @@
//
// FAShortDetailModel.swift
// Fableon
//
// Created by 鸿 on 2025/8/29.
//
import UIKit
import SwiftUI
import SmartCodable
class FAShortDetailModel: NSObject, Identifiable, SmartCodable {
var video_info: FAVideoInfoModel?
var shortPlayInfo: FAShortPlayModel?
var episodeList: [FAVideoInfoModel]?
var is_collect: Bool?
var share_coin: Int?
required override init() { }
}

View File

@ -0,0 +1,39 @@
//
// FAShortPlayModel.swift
// Fableon
//
// Created by 鸿 on 2025/8/26.
//
import UIKit
import SmartCodable
class FAShortPlayModel: NSObject, Identifiable, SmartCodable {
required override init() { }
var id: String?
var fa_description: String?
var name: String?
var watch_total: Int?
var current_episode: String?
var image_url: String?
var is_collect: Bool?
var collect_total: Int?
var episode_total: Int?
var horizontally_img: String?
var category: [String]?
var short_play_id: String?
var short_play_video_id: String?
var video_info: FAVideoInfoModel?
@SmartIgnored
var cellHeight: CGFloat = 0
static func mappingForKey() -> [SmartKeyTransformer]? {
return [
CodingKeys.fa_description <--- ["description", "short_video_description"],
CodingKeys.name <--- ["short_video_title", "name"]
]
}
}

View File

@ -0,0 +1,25 @@
//
// FAVideoInfoModel.swift
// Fableon
//
// Created by 鸿 on 2025/8/29.
//
import UIKit
import SmartCodable
class FAVideoInfoModel: NSObject, Identifiable, SmartCodable {
required override init() { }
var short_play_id: String?
var short_play_video_id: String?
var video_url: String?
var episode: String?
var coins: Int?
///
var is_lock: Bool?
var image_url: String?
///
var play_seconds: Int?
}

View File

@ -0,0 +1,46 @@
//
// FAPlayerEpUIButton.swift
// Fableon
//
// Created by 鸿 on 2025/9/18.
//
import SwiftUI
struct FAPlayerEpUIButton: View {
var text: String?
var clickHandle: (() -> Void)?
var body: some View {
HStack() {
Spacer(minLength: 14)
HStack {
HStack(spacing: 9) {
Image("Frame 3008")
Text(text ?? "")
.font(Font.font(size: 12, weight: .regular))
.foregroundStyle(Color.FFFFFF)
}
Spacer()
Image("Frame 3009")
}
Spacer(minLength: 14)
}
.frame(height: 32)
.frame(maxWidth: .infinity)
.background(Color.init(.color_FFFFFF).opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
self.clickHandle?()
}
}
}
#Preview {
FAPlayerEpUIButton()
}

View File

@ -0,0 +1,187 @@
//
// FAEpMenuView.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
import YYCategories
class FAEpMenuView: UIView {
override var intrinsicContentSize: CGSize {
return CGSize(width: UIScreen.width, height: 35)
}
var didSelectedIndex: ((_ index: Int) -> Void)?
var dataArr: [String] = [] {
didSet {
self.reloadData()
}
}
var selectedIndex: Int = 0 {
didSet {
self.buttonArr.forEach {
$0.isSelected = $0.tag == selectedIndex
if $0.isSelected {
self.updateLinePosition(to: $0, true)
}
}
}
}
private lazy var buttonArr: [UIButton] = []
//MARK: UI
private lazy var scrollView: FAScrollView = {
let scrollView = FAScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
private lazy var lineView: UIView = {
let view = UIView()
view.layer.cornerRadius = 2
view.layer.masksToBounds = true
view.backgroundColor = ._35_A_4_FE
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
fa_setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func reloadData() {
buttonArr.forEach {
$0.removeFromSuperview()
}
buttonArr.removeAll()
let count = self.dataArr.count
var previousButton: UIButton?
dataArr.enumerated().forEach {
let text = $1
let normalStrig = NSMutableAttributedString(string: $1)
normalStrig.yy_color = ._999999
normalStrig.yy_font = .font(ofSize: 16, weight: .init(900))
let selectedString = NSMutableAttributedString(string: $1)
selectedString.yy_color = ._35_A_4_FE
selectedString.yy_font = .font(ofSize: 16, weight: .init(900))
var config = UIButton.Configuration.plain()
config.background.backgroundColor = .clear
config.contentInsets = .zero
let button = UIButton(configuration: config)
button.tag = $0
button.configurationUpdateHandler = { button in
let font = UIFont.font(ofSize: 16, weight: .init(900))
if button.isSelected {
button.configuration?.attributedTitle = AttributedString(text, attributes: AttributeContainer([
.font : font,
.foregroundColor : UIColor._35_A_4_FE
]))
} else {
button.configuration?.attributedTitle = AttributedString(text, attributes: AttributeContainer([
.font : font,
.foregroundColor : UIColor._999999
]))
}
}
button.addAction(UIAction(handler: { [weak self] _ in
guard let self = self else { return }
self.selectedIndex = button.tag
self.didSelectedIndex?(self.selectedIndex)
}), for: .touchUpInside)
button.isSelected = $0 == selectedIndex
self.scrollView.addSubview(button)
self.buttonArr.append(button)
if previousButton == nil {
button.snp.makeConstraints { make in
make.left.equalToSuperview().offset(30)
make.top.equalToSuperview()
make.height.equalTo(35)
}
} else if let previousButton = previousButton, count - 1 == $0 {
button.snp.makeConstraints { make in
make.top.equalToSuperview()
make.left.equalTo(previousButton.snp.right).offset(40)
make.height.equalTo(35)
make.right.equalToSuperview().offset(-30)
}
} else if let previousButton = previousButton {
button.snp.makeConstraints { make in
make.top.equalToSuperview()
make.left.equalTo(previousButton.snp.right).offset(40)
make.height.equalTo(35)
}
}
if button.isSelected {
self.updateLinePosition(to: button, false)
}
previousButton = button
}
}
@objc private func handleButton(sender: UIButton) {
self.selectedIndex = sender.tag
self.didSelectedIndex?(self.selectedIndex)
}
private func updateLinePosition(to button: UIButton, _ isAnimate: Bool) {
lineView.snp.remakeConstraints { make in
make.bottom.equalTo(button)
make.width.equalTo(10)
make.height.equalTo(4)
make.centerX.equalTo(button)
}
if isAnimate {
UIView.animate(withDuration: 0.3) {
self.layoutIfNeeded()
}
}
}
}
extension FAEpMenuView {
private func fa_setupLayout() {
addSubview(scrollView)
scrollView.addSubview(lineView)
scrollView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.bottom.equalToSuperview()
}
}
}

View File

@ -0,0 +1,41 @@
//
// FAEpSelectorCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/29.
//
import UIKit
class FAEpSelectorCell: UICollectionViewCell {
var model: FAVideoInfoModel? {
didSet {
numberLabel.text = model?.episode
}
}
var fa_isSelected: Bool = false {
didSet {
if fa_isSelected {
numberLabel.textColor = ._35_A_4_FE
self.contentView.backgroundColor = .FFFFFF
self.contentView.layer.borderColor = UIColor._35_A_4_FE.cgColor
} else {
numberLabel.textColor = .FFFFFF.withAlphaComponent(0.8)
self.contentView.backgroundColor = .FFFFFF.withAlphaComponent(0.25)
self.contentView.layer.borderColor = UIColor.clear.cgColor
}
}
}
@IBOutlet weak var numberLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
self.layer.masksToBounds = false
self.contentView.layer.cornerRadius = 3
self.contentView.layer.borderWidth = 1
}
}

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FAEpSelectorCell" customModule="Fableon" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="185" height="250"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="185" height="250"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yhy-MQ-hyT">
<rect key="frame" x="88.666666666666671" y="115.33333333333333" width="8" height="19.333333333333329"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="16"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<constraints>
<constraint firstItem="yhy-MQ-hyT" firstAttribute="centerX" secondItem="gTV-IL-0wX" secondAttribute="centerX" id="CFQ-ov-ult"/>
<constraint firstItem="yhy-MQ-hyT" firstAttribute="centerY" secondItem="gTV-IL-0wX" secondAttribute="centerY" id="Ov2-Ct-sBR"/>
</constraints>
<size key="customSize" width="185" height="250"/>
<connections>
<outlet property="numberLabel" destination="yhy-MQ-hyT" id="SSw-lq-oFo"/>
</connections>
<point key="canvasLocation" x="241.98473282442748" y="111.9718309859155"/>
</collectionViewCell>
</objects>
</document>

View File

@ -0,0 +1,259 @@
//
// FAEpSelectorView.swift
// Fableon
//
// Created by 鸿 on 2025/9/23.
//
import UIKit
import HWPanModal
class FAEpSelectorView: FAPanModalContentView {
var didSelected: ((_ index: Int) -> Void)?
var model: FAShortDetailModel? {
didSet {
titleLabel.text = model?.shortPlayInfo?.name
collectionView.reloadData()
let epList = model?.episodeList ?? []
var menuDataArr = [String]()
let totalEpisode = epList.count
var index = 0
var remainingEpisodes = totalEpisode
while remainingEpisodes > 0 {
let minIndex = index * 24
var maxIndex = minIndex + 23
if maxIndex >= epList.count {
maxIndex = epList.count - 1
}
let minEpisode = epList[minIndex].episode ?? "0"
let maxEpisode = epList[maxIndex].episode ?? "0"
if minEpisode == maxEpisode {
menuDataArr.append("\(minEpisode)")
} else {
menuDataArr.append("\(minEpisode)-\(maxEpisode)")
}
remainingEpisodes -= 24
index += 1
}
self.menuView.dataArr = menuDataArr
}
}
var selectedIndex: Int = 0 {
didSet {
collectionView.reloadData()
}
}
private var isDecelerating = false
private var isDragging = false
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 16, weight: .medium)
label.textColor = .FFFFFF
return label
}()
private lazy var menuView: FAEpMenuView = {
let view = FAEpMenuView()
view.didSelectedIndex = { [weak self] index in
guard let self = self else { return }
let epList = self.model?.episodeList ?? []
var row = 0
if index > 0 {
row = index * 24 + 10
let count = epList.count
if row >= count {
row = count - 1
}
}
let indexPath = IndexPath.init(row: row, section: 0)
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true)
}
return view
}()
private lazy var cvLayout: FAWaterfallFlowLayout = {
let layout = FAWaterfallFlowLayout()
layout.delegate = self
return layout
}()
private lazy var collectionView: FACollectionView = {
let collectionView = FACollectionView(frame: .zero, collectionViewLayout: cvLayout)
collectionView.contentInset = .init(top: 10, left: 0, bottom: UIScreen.safeBottom + 10, right: 0)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(UINib(nibName: "FAEpSelectorCell", bundle: nil), forCellWithReuseIdentifier: "cell")
return collectionView
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .clear
self.contentHeight = 350 + UIScreen.safeBottom
fa_setupLayout()
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func backgroundConfig() -> HWBackgroundConfig {
let config = HWBackgroundConfig()
config.backgroundAlpha = 0.6
return config
}
override func layoutSubviews() {
super.layoutSubviews()
}
override func present(in view: UIView?) {
super.present(in: view)
self.hw_contentView.fa_addEffectView(style: .dark)
let r = self.cornerRadius()
self.hw_contentView.fa_setRoundedCorner(topLeft: r, topRight: r, bottomLeft: 0, bottomRight: 0)
}
}
extension FAEpSelectorView {
private func fa_setupLayout() {
addSubview(titleLabel)
addSubview(menuView)
addSubview(collectionView)
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(20)
make.right.lessThanOrEqualToSuperview().offset(-20)
make.top.equalToSuperview().offset(18)
}
menuView.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalToSuperview().offset(55)
}
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(107)
}
}
}
//MARK: UICollectionViewDelegate, UICollectionViewDataSource
extension FAEpSelectorView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FAEpSelectorCell
cell.model = model?.episodeList?[indexPath.row]
cell.fa_isSelected = selectedIndex == indexPath.row
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
model?.episodeList?.count ?? 0
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if self.selectedIndex == indexPath.row { return }
self.selectedIndex = indexPath.row
self.didSelected?(indexPath.row)
self.dismiss(animated: true) {
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if isDragging || isDecelerating {
updateMuneSelectedIndex()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
isDecelerating = false
updateMuneSelectedIndex()
}
func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
isDecelerating = true
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDragging = true
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
isDragging = false
}
func updateMuneSelectedIndex() {
let epList = model?.episodeList ?? []
let indexPathArr = collectionView.indexPathsForVisibleItems
var minRow = epList.count - 1
var maxRow = 0
for indexPath in indexPathArr {
if indexPath.row < minRow {
minRow = indexPath.row
}
if indexPath.row > maxRow {
maxRow = indexPath.row
}
}
let selectedIndex = maxRow / 24
if menuView.selectedIndex != selectedIndex {
menuView.selectedIndex = selectedIndex
}
}
}
//MARK: FAWaterfallMutiSectionDelegate
extension FAEpSelectorView: FAWaterfallMutiSectionDelegate {
func heightForRowAtIndexPath(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, indexPath: IndexPath, itemWidth: CGFloat) -> CGFloat {
return 44
}
func columnNumber(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> Int {
return 6
}
func lineSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
return 15
}
func interitemSpacing(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> CGFloat {
return 15
}
func insetForSection(collectionView collection: UICollectionView, layout: FAWaterfallFlowLayout, section: Int) -> UIEdgeInsets {
return .init(top: 0, left: 20, bottom: 0, right: 20)
}
}

View File

@ -0,0 +1,42 @@
//
// FAPlayerDetailCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/1.
//
import UIKit
import JXPlayer
class FAPlayerDetailCell: JXPlayerListCell {
override var ControlViewClass: JXPlayerListControlView.Type {
return FAPlayerDetailControlView.self
}
override var model: Any? {
didSet {
let model = self.model as? FAVideoInfoModel
self.player.setPlayUrl(url: model?.video_url ?? "")
}
}
var shortModel: FAShortPlayModel? {
didSet {
let controlView = self.controlView as? FAPlayerDetailControlView
controlView?.shortModel = shortModel
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,235 @@
//
// FAPlayerDetailControlView.swift
// Fableon
//
// Created by 鸿 on 2025/9/16.
//
import UIKit
import JXPlayer
import SwiftUI
class FAPlayerDetailControlView: JXPlayerListControlView {
override var viewModel: JXPlayerListViewModel? {
didSet {
self.viewModel?.addObserver(self, forKeyPath: "isPlaying", context: nil)
}
}
var fa_viewModel: FAShortDetailViewModel? {
return self.viewModel as? FAShortDetailViewModel
}
override var model: Any? {
didSet {
// let model = self.model as? FAVideoInfoModel
updateEp()
}
}
var shortModel: FAShortPlayModel? {
didSet {
updateEp()
shortNameLabel.text = shortModel?.name
textLabel.text = shortModel?.fa_description
collectButton.isSelected = shortModel?.is_collect == true
}
}
override var durationTime: TimeInterval {
didSet {
updateProgress()
}
}
override var currentTime: TimeInterval {
didSet {
updateProgress()
}
}
override var isCurrent: Bool {
didSet {
playButton.setNeedsUpdateConfiguration()
}
}
private lazy var epButton: UIHostingController<FAPlayerEpUIButton> = {
let view = FAPlayerEpUIButton()
let hc = UIHostingController(rootView: view)
hc.view.backgroundColor = .clear
return hc
}()
private lazy var progressView: FAPlayerProgressView = {
let view = FAPlayerProgressView()
view.insets = .init(top: 10, left: 16, bottom: 10, right: 16)
view.panFinish = { [weak self] progress in
guard let self = self else { return }
self.viewModel?.seekTo(Float(progress))
}
return view
}()
private lazy var textLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular);
label.textColor = UIColor(named: .color_FFFFFF)!.withAlphaComponent(0.8)
label.numberOfLines = 2
return label
}()
private lazy var shortNameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 16, weight: .medium)
label.textColor = UIColor(named: .color_FFFFFF)
return label
}()
private lazy var playButton: UIButton = {
let config = UIButton.Configuration.plain()
let button = UIButton(configuration: config, primaryAction: UIAction(handler: { [weak self] _ in
self?.fa_viewModel?.userSwitchPlayAndPause()
}))
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
if self.viewModel?.isPlaying == true || !isCurrent {
button.configuration?.image = UIImage(named: "pause_icon")
} else {
button.configuration?.image = UIImage(named: "play_icon_01")
}
}
return button
}()
private lazy var collectButton: UIButton = {
var config = UIButton.Configuration.plain()
config.background.backgroundColor = .clear
let button = UIButton(configuration: config)
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
if button.isSelected {
button.configuration?.image = UIImage(named: "collect_star_icon_selected")
} else {
button.configuration?.image = UIImage(named: "collect_star_icon")
}
}
button.addAction(UIAction(handler: { [weak self] _ in
guard let self = self else { return }
guard let shortPlayId = self.shortModel?.short_play_id else { return }
let videoId = (self.model as? FAVideoInfoModel)?.short_play_video_id
let isCollect = !(self.shortModel?.is_collect ?? false)
FAAPI.requestShortCollect(isCollect: isCollect, shortPlayId: shortPlayId, videoId: videoId, success: nil)
}), for: .touchUpInside)
return button
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override init(frame: CGRect) {
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(updateShortCollectStateNotification), name: FAAPI.updateShortCollectStateNotification, object: nil)
fa_setupLayout()
}
@MainActor required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "isPlaying" {
playButton.setNeedsUpdateConfiguration()
}
}
private func updateEp() {
let model = self.model as? FAVideoInfoModel
let text = "Ep.##".localizedReplace(text: model?.episode ?? "") + "/" + "Ep.##".localizedReplace(text: "\(shortModel?.episode_total ?? 0)")
var view = FAPlayerEpUIButton(text: text)
view.clickHandle = { [weak self] in
self?.fa_viewModel?.onEpSelectorView()
}
epButton.rootView = view
}
private func updateProgress() {
guard durationTime > 0 else {
progressView.progress = 0
return
}
progressView.progress = currentTime / durationTime
}
@objc private func updateShortCollectStateNotification(sender: Notification) {
guard let userInfo = sender.userInfo else { return }
guard let shortPlayId = userInfo["id"] as? String else { return }
guard let state = userInfo["state"] as? Bool else { return }
guard shortPlayId == self.shortModel?.short_play_id else { return }
self.shortModel?.is_collect = state
collectButton.isSelected = state
}
}
extension FAPlayerDetailControlView {
private func fa_setupLayout() {
addSubview(epButton.view)
addSubview(progressView)
addSubview(textLabel)
addSubview(shortNameLabel)
addSubview(playButton)
addSubview(collectButton)
epButton.view.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().offset(-(UIScreen.safeBottom + 10))
}
progressView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.centerX.equalToSuperview()
make.bottom.equalTo(epButton.view.snp.top).offset(-8)
}
textLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.right.lessThanOrEqualToSuperview().offset(-84)
make.bottom.equalTo(progressView.snp.top).offset(1)
}
shortNameLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.bottom.equalTo(textLabel.snp.top).offset(-5)
make.right.lessThanOrEqualToSuperview().offset(-84)
}
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
}
collectButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(UIScreen.safeTop)
make.right.equalToSuperview().offset(-16)
make.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,211 @@
//
// FAPlayerProgressView.swift
// Fableon
//
// Created by 鸿 on 2025/9/23.
//
import UIKit
import YYText
class FAPlayerProgressView: UIView {
///
var panStart: (() -> Void)?
///
var panChange: ((_ progress: CGFloat) -> Void)?
///
var panFinish: ((_ progress: CGFloat) -> Void)?
var progress: CGFloat = 0 {
didSet {
if !isPaning {
setNeedsDisplay()
}
}
}
///
private var tempProgress: CGFloat = 0
///
private var panProgress: CGFloat = 0
var progressColor = UIColor(named: .color_FFFFFF)!.withAlphaComponent(0.2)
var currentProgress = UIColor(named: .color_FFFFFF)!
var lineWidth: CGFloat = 3
///
var isLoading = false {
didSet {
if isLoading {
if gradientTimer == nil {
gradientTimer = Timer.scheduledTimer(timeInterval: 0.05, target: YYTextWeakProxy(target: self), selector: #selector(handleGradientTimer), userInfo: nil, repeats: true)
}
} else {
gradientTimer?.invalidate()
gradientTimer = nil
}
}
}
var insets: UIEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16) {
didSet {
self.invalidateIntrinsicContentSize()
setNeedsDisplay()
}
}
private(set) lazy var panGesture: UIPanGestureRecognizer = {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(sender:)))
return pan
}()
private(set) lazy var tagGesture: UITapGestureRecognizer = {
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(sender:)))
return tap
}()
///
private var isPaning: Bool = false
private var gradientTimer: Timer?
private var gradientValue: CGFloat = 0
override var intrinsicContentSize: CGSize {
return .init(width: UIScreen.width, height: lineWidth + insets.top + insets.bottom)
}
override init(frame: CGRect) {
super.init(frame: frame)
// self.backgroundColor = progressColor
self.backgroundColor = .clear
self.addGestureRecognizer(panGesture)
self.addGestureRecognizer(tagGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setNeedsDisplay()
}
@objc private func handleGradientTimer() {
gradientValue += 0.1
if gradientValue > 1 {
gradientValue = 0
}
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let width = rect.width
let progressX = insets.left
let progressY = insets.top
let progressWidth = width - insets.left - insets.right
if isLoading, !isPaning {
//
let colorSpace = CGColorSpaceCreateDeviceRGB()
let colors: [CGColor] = [
UIColor.clear.cgColor,
UIColor.white.cgColor,
UIColor.clear.cgColor
]
let locations: [CGFloat] = [0.0, gradientValue, 1.0]
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
return
}
let gradientRect = CGRect(x: progressX,
y: progressY,
width: progressWidth,
height: lineWidth)
//
let startPoint = CGPoint(x: rect.minX, y: rect.minY)
let endPoint = CGPoint(x: rect.maxX, y: rect.maxY)
//
context.saveGState()
context.clip(to: gradientRect)
//
context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: [])
} else {
var progress = self.progress
if self.isPaning {
progress = self.panProgress
}
///
let progressPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth, height: lineWidth), cornerRadius: lineWidth / 2)
context.addPath(progressPath.cgPath)
context.setFillColor(progressColor.cgColor)
context.fillPath()
///
let currentPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth * progress, height: lineWidth), cornerRadius: lineWidth / 2)
context.addPath(currentPath.cgPath)
context.setFillColor(currentProgress.cgColor)
context.fillPath()
}
}
}
extension FAPlayerProgressView {
@objc func handlePanGesture(sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
self.isPaning = true
self.tempProgress = self.progress
sender.setTranslation(CGPoint(x: 0, y: 0), in: self)
self.panStart?()
case .changed:
let point = sender.translation(in: self)
let offsetX = point.x / (self.width - self.insets.left - self.insets.right)
self.panProgress = self.tempProgress + offsetX
if self.panProgress < 0 {
self.panProgress = 0
}
self.panChange?(self.panProgress)
setNeedsDisplay()
default:
self.isPaning = false
self.panFinish?(self.panProgress)
self.panProgress = 0
}
}
@objc func handleTapGesture(sender: UITapGestureRecognizer) {
let point = sender.location(in: self)
let offsetX = (point.x - self.insets.left) / (self.width - self.insets.left - self.insets.right)
self.panFinish?(offsetX)
}
}

View File

@ -0,0 +1,150 @@
//
// FAPlayerDetailViewController.swift
// Fableon
//
// Created by 鸿 on 2025/9/1.
//
import UIKit
import JXPlayer
import FDFullscreenPopGesture
class FAPlayerDetailViewController: JXPlayerListViewController {
var shortPlayId: String?
override var ViewModelClass: JXPlayerListViewModel.Type {
return FAShortDetailViewModel.self
}
var fa_viewModel: FAShortDetailViewModel {
return self.viewModel as! FAShortDetailViewModel
}
private lazy var returnButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: "Frame 3011"), for: .normal)
button.addAction(UIAction(handler: { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
}), for: .touchUpInside)
return button
}()
private lazy var epLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 18, weight: .regular)
label.textColor = .init(named: .color_FFFFFF)
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
self.fd_interactivePopDisabled = true
view.backgroundColor = .init(named: .color_000000)
self.fa_viewModel.shortPlayId = shortPlayId ?? ""
self.register(FAPlayerDetailCell.self, forCellWithReuseIdentifier: "FAPlayerDetailCell")
self.delegate = self
self.dataSource = self
requestDetailList()
fa_setupLayout()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.viewModel.currentCell?.pause()
}
override var previousVideoUrl: String? {
return self.fa_viewModel.previousEpisode?.video_url
}
override var nextVideoUrl: String? {
return self.fa_viewModel.nextEpisode?.video_url
}
override func play() {
super.play()
let videoInfo = self.viewModel.currentCell?.model as? FAVideoInfoModel
FAAPI.requestCreatePlayHistory(videoId: videoInfo?.short_play_video_id, shortPlayId: videoInfo?.short_play_id)
}
}
extension FAPlayerDetailViewController {
private func fa_setupLayout() {
view.addSubview(returnButton)
view.addSubview(epLabel)
returnButton.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview().offset(UIScreen.safeTop)
make.height.equalTo(44)
}
epLabel.snp.makeConstraints { make in
make.centerY.equalTo(returnButton)
make.left.equalTo(returnButton.snp.right).offset(4)
}
}
}
//MARK: JXPlayerListViewControllerDelegate JXPlayerListViewControllerDataSource
extension FAPlayerDetailViewController: JXPlayerListViewControllerDelegate, JXPlayerListViewControllerDataSource {
func jx_playerListViewController(_ viewController: JXPlayerListViewController, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = self.dequeueReusableCell(withReuseIdentifier: "FAPlayerDetailCell", for: indexPath) as! FAPlayerDetailCell
cell.model = self.fa_viewModel.dataArr[indexPath.section].episodeList?[indexPath.row]
cell.shortModel = self.fa_viewModel.dataArr[indexPath.section].shortPlayInfo
return cell
}
func jx_playerListViewController(_ viewController: JXPlayerListViewController, numberOfItemsInSection section: Int) -> Int {
self.fa_viewModel.dataArr[section].episodeList?.count ?? 0
}
func jx_numberOfSections(in viewController: JXPlayerListViewController) -> Int {
self.fa_viewModel.dataArr.count
}
func jx_playerListViewController(_ viewController: JXPlayerListViewController, didChangeIndexPathForVisible indexPath: IndexPath) {
let model = self.fa_viewModel.dataArr[indexPath.section].episodeList?[indexPath.row]
epLabel.text = "Ep.\(model?.episode ?? "")"
}
func jx_shouldAutoScrollNextEpisode(_ viewController: JXPlayerListViewController) -> Bool {
if let _ = self.fa_viewModel.popView {
return false
} else {
return true
}
}
}
extension FAPlayerDetailViewController {
private func requestDetailList() {
self.fa_viewModel.requestDetailData { [weak self] code in
guard let self = self else { return }
}
}
}

View File

@ -0,0 +1,72 @@
//
// Untitled.swift
// Fableon
//
// Created by 鸿 on 2025/8/29.
//
import SwiftUI
import JXPlayer
//@MainActor
class FAShortDetailViewModel: JXPlayerListViewModel, ObservableObject {
private(set) var dataArr: [FAShortDetailModel] = []
var shortPlayId: String = ""
var previousEpisode: FAVideoInfoModel? {
guard dataArr.count > 0 else { return nil }
let detailModel = dataArr[self.currentIndexPath.section]
let row = self.currentIndexPath.row - 1
if row < 0 { return nil }
return detailModel.episodeList?[row]
}
var nextEpisode: FAVideoInfoModel? {
guard dataArr.count > 0 else { return nil }
let detailModel = dataArr[self.currentIndexPath.section]
let row = self.currentIndexPath.row + 1
if row >= (detailModel.episodeList?.count ?? 0) { return nil }
return detailModel.episodeList?[row]
}
weak var popView: UIView?
func requestDetailData(completer: ((_ code: Int) -> Void)?) {
FAAPI.requestShortDetailData(shortPlayId: shortPlayId) { [weak self] model, code, msg in
guard let self = self else { return }
if let model = model {
self.dataArr.removeAll()
self.dataArr.append(model)
self.playerListVC?.reloadData {
self.playerListVC?.play()
}
}
completer?(code ?? -1)
}
}
}
extension FAShortDetailViewModel {
func onEpSelectorView() {
let view = FAEpSelectorView()
view.selectedIndex = self.currentIndexPath.row
view.model = self.dataArr[currentIndexPath.section]
view.didSelected = { [weak self] index in
guard let self = self else { return }
self.playerListVC?.scrollToItem(indexPath: IndexPath(row: index, section: currentIndexPath.section), animated: false)
}
view.present(in: nil)
self.popView = view
}
}

View File

@ -0,0 +1,73 @@
//
// FARecommendViewController.swift
// Fableon
//
// Created by 鸿 on 2025/9/30.
//
import UIKit
import JXPlayer
class FARecommendViewController: JXPlayerListViewController {
override var contentSize: CGSize {
return .init(width: UIScreen.width, height: UIScreen.height - UIScreen.tabBarHeight)
}
override var ViewModelClass: JXPlayerListViewModel.Type {
return FARecommendViewModel.self
}
var fa_viewModel: FARecommendViewModel {
return self.viewModel as! FARecommendViewModel
}
override func viewDidLoad() {
super.viewDidLoad()
self.register(FARecommendPlayerCell.self, forCellWithReuseIdentifier: "cell")
self.delegate = self
self.dataSource = self
self.fa_viewModel.requestDataArr(page: 1)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.viewModel.currentCell?.pause()
}
override var previousVideoUrl: String? {
return self.fa_viewModel.previousEpisode?.video_url
}
override var nextVideoUrl: String? {
return self.fa_viewModel.nextEpisode?.video_url
}
}
//MARK: JXPlayerListViewControllerDataSource
extension FARecommendViewController: JXPlayerListViewControllerDataSource {
func jx_playerListViewController(_ viewController: JXPlayerListViewController, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = self.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FARecommendPlayerCell
cell.model = fa_viewModel.dataArr[indexPath.row]
return cell
}
func jx_playerListViewController(_ viewController: JXPlayerListViewController, numberOfItemsInSection section: Int) -> Int {
return fa_viewModel.dataArr.count
}
}
//MARK: JXPlayerListViewControllerDelegate
extension FARecommendViewController: JXPlayerListViewControllerDelegate {
}

View File

@ -0,0 +1,35 @@
//
// FARecommendPlayerCell.swift
// Fableon
//
// Created by 鸿 on 2025/9/30.
//
import UIKit
import JXPlayer
class FARecommendPlayerCell: JXPlayerListCell {
override var ControlViewClass: JXPlayerListControlView.Type {
return FARecommendPlayerControlView.self
}
override var model: Any? {
didSet {
let model = self.model as? FAShortPlayModel
let videoInfo = model?.video_info
self.player.setPlayUrl(url: videoInfo?.video_url ?? "")
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,237 @@
//
// FARecommendPlayerControlView.swift
// Fableon
//
// Created by 鸿 on 2025/10/9.
//
import UIKit
import JXPlayer
import SwiftUI
class FARecommendPlayerControlView: JXPlayerListControlView {
override var viewModel: JXPlayerListViewModel? {
didSet {
self.viewModel?.addObserver(self, forKeyPath: "isPlaying", context: nil)
}
}
var fa_viewModel: FARecommendViewModel? {
return self.viewModel as? FARecommendViewModel
}
override var model: Any? {
didSet {
let shortModel = self.model as? FAShortPlayModel
// let videoInfo = shortModel?.video_info
updateEp()
shortNameLabel.text = shortModel?.name
textLabel.text = shortModel?.fa_description
collectButton.isSelected = shortModel?.is_collect == true
}
}
var shortModel: FAShortPlayModel? {
return self.model as? FAShortPlayModel
}
override var durationTime: TimeInterval {
didSet {
updateProgress()
}
}
override var currentTime: TimeInterval {
didSet {
updateProgress()
}
}
override var isCurrent: Bool {
didSet {
playButton.setNeedsUpdateConfiguration()
}
}
private lazy var epButton: UIHostingController<FAPlayerEpUIButton> = {
let view = FAPlayerEpUIButton()
let hc = UIHostingController(rootView: view)
hc.view.backgroundColor = .clear
return hc
}()
private lazy var progressView: FAPlayerProgressView = {
let view = FAPlayerProgressView()
view.insets = .init(top: 10, left: 16, bottom: 10, right: 16)
view.panFinish = { [weak self] progress in
guard let self = self else { return }
self.viewModel?.seekTo(Float(progress))
}
return view
}()
private lazy var textLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular);
label.textColor = UIColor(named: .color_FFFFFF)!.withAlphaComponent(0.8)
label.numberOfLines = 2
return label
}()
private lazy var shortNameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 16, weight: .medium)
label.textColor = UIColor(named: .color_FFFFFF)
return label
}()
private lazy var playButton: UIButton = {
let config = UIButton.Configuration.plain()
let button = UIButton(configuration: config, primaryAction: UIAction(handler: { [weak self] _ in
self?.fa_viewModel?.userSwitchPlayAndPause()
}))
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
if self.viewModel?.isPlaying == true || !isCurrent {
button.configuration?.image = UIImage(named: "pause_icon")
} else {
button.configuration?.image = UIImage(named: "play_icon_01")
}
}
return button
}()
private lazy var collectButton: UIButton = {
var config = UIButton.Configuration.plain()
config.background.backgroundColor = .clear
let button = UIButton(configuration: config)
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
if button.isSelected {
button.configuration?.image = UIImage(named: "collect_star_icon_selected")
} else {
button.configuration?.image = UIImage(named: "collect_star_icon")
}
}
button.addAction(UIAction(handler: { [weak self] _ in
guard let self = self else { return }
guard let shortPlayId = self.shortModel?.short_play_id else { return }
let videoId = (self.model as? FAVideoInfoModel)?.short_play_video_id
let isCollect = !(self.shortModel?.is_collect ?? false)
FAAPI.requestShortCollect(isCollect: isCollect, shortPlayId: shortPlayId, videoId: videoId, success: nil)
}), for: .touchUpInside)
return button
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override init(frame: CGRect) {
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(updateShortCollectStateNotification), name: FAAPI.updateShortCollectStateNotification, object: nil)
fa_setupLayout()
}
@MainActor required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "isPlaying" {
playButton.setNeedsUpdateConfiguration()
}
}
private func updateEp() {
// let model = self.model as? FAVideoInfoModel
let model = self.model as? FAShortPlayModel
let videoInfo = model?.video_info
let text = "Ep.##".localizedReplace(text: videoInfo?.episode ?? "") + "/" + "Ep.##".localizedReplace(text: "\(model?.episode_total ?? 0)")
var view = FAPlayerEpUIButton(text: text)
view.clickHandle = { [weak self] in
self?.fa_viewModel?.pushPlayerDetail(self?.shortModel)
}
epButton.rootView = view
}
private func updateProgress() {
guard durationTime > 0 else {
progressView.progress = 0
return
}
progressView.progress = currentTime / durationTime
}
@objc private func updateShortCollectStateNotification(sender: Notification) {
guard let userInfo = sender.userInfo else { return }
guard let shortPlayId = userInfo["id"] as? String else { return }
guard let state = userInfo["state"] as? Bool else { return }
guard shortPlayId == self.shortModel?.short_play_id else { return }
self.shortModel?.is_collect = state
collectButton.isSelected = state
}
}
extension FARecommendPlayerControlView {
private func fa_setupLayout() {
addSubview(epButton.view)
addSubview(progressView)
addSubview(textLabel)
addSubview(shortNameLabel)
addSubview(playButton)
addSubview(collectButton)
epButton.view.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().offset(-10)
}
progressView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.centerX.equalToSuperview()
make.bottom.equalTo(epButton.view.snp.top).offset(-8)
}
textLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.right.lessThanOrEqualToSuperview().offset(-84)
make.bottom.equalTo(progressView.snp.top).offset(1)
}
shortNameLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.bottom.equalTo(textLabel.snp.top).offset(-5)
make.right.lessThanOrEqualToSuperview().offset(-84)
}
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
}
collectButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(UIScreen.safeTop)
make.right.equalToSuperview().offset(-16)
make.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,84 @@
//
// FARecommendViewModel.swift
// Fableon
//
// Created by 鸿 on 2025/9/30.
//
import UIKit
import JXPlayer
class FARecommendViewModel: JXPlayerListViewModel {
private(set) var dataArr: [FAShortPlayModel] = []
var previousEpisode: FAVideoInfoModel? {
guard dataArr.count > 0 else { return nil }
let row = self.currentIndexPath.row - 1
if row < 0 { return nil }
return dataArr[row].video_info
}
var nextEpisode: FAVideoInfoModel? {
guard dataArr.count > 0 else { return nil }
let row = self.currentIndexPath.row + 1
if row >= dataArr.count { return nil }
return dataArr[row].video_info
}
private func addDataArr(dataArr: [FAShortPlayModel]) {
guard dataArr.count > 0 else { return }
var indexPaths: [IndexPath] = []
var startRow = self.dataArr.count
dataArr.forEach { _ in
indexPaths.append(IndexPath(row: startRow, section: 0))
startRow += 1
}
self.dataArr += dataArr
CATransaction.setCompletionBlock(nil)
CATransaction.begin()
self.playerListVC?.collectionView.insertItems(at: indexPaths)
CATransaction.commit()
}
func pushPlayerDetail(_ model: FAShortPlayModel?) {
guard let model = model else { return }
let vc = FAPlayerDetailViewController()
vc.shortPlayId = model.short_play_id
FATool.topViewController?.navigationController?.pushViewController(vc, animated: true)
}
}
extension FARecommendViewModel {
func requestDataArr(page: Int, completer: (() -> Void)? = nil) {
FAAPI.requestRecommendVideo(page: page) { [weak self] listModel in
guard let self = self else { return }
if let listModel = listModel, let list = listModel.list {
if page == 1 {
self.playerListVC?.clearData()
self.dataArr = list
self.playerListVC?.reloadData { [weak self] in
self?.playerListVC?.scrollToItem(indexPath: .init(row: 0, section: 0), animated: false)
}
} else {
self.addDataArr(dataArr: list)
}
// self.pagination = listModel.pagination
}
completer?()
}
}
}

View File

@ -0,0 +1,27 @@
//
// FAEmpty.swift
// Fableon
//
// Created by 鸿 on 2025/10/14.
//
import UIKit
import LYEmptyView
struct FAEmpty {
static func fa_emptyView(image: UIImage?, title: String?) -> LYEmptyView {
let view = LYEmptyView.emptyActionView(with: image, titleStr: title, detailStr: nil, btnTitleStr: nil) {
// btnClickBlock?()
}
view?.titleLabFont = .font(ofSize: 14, weight: .medium)
view?.titleLabTextColor = .FFFFFF
view?.contentViewOffset = -100
view?.subViewMargin = 25
return view!
}
}

View File

@ -0,0 +1,26 @@
//
// FADeviceIDManager.swift
// Fableon
//
// Created by 鸿 on 2025/9/15.
//
import Foundation
import UIKit
class FADeviceIDManager {
static let shared = FADeviceIDManager()
private let key = "com.fableon.uniqueDeviceID"
private init() {}
lazy var id: String = {
if let savedID = FAKeychainHelper.shared.read(key: key) {
return savedID
} else {
let newID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
FAKeychainHelper.shared.save(key: key, value: newID)
return newID
}
}()
}

Some files were not shown because too many files have changed in this diff Show More