feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -0,0 +1,109 @@
//
// MediaPreviewItem.swift
// suixinkan
//
import UIKit
///
public struct MediaPreviewItem: Hashable {
///
public enum MediaType: Equatable {
case image
case video
}
///
public enum Source {
case remoteImage(URL)
case localImageFile(URL)
case image(UIImage)
case remoteVideo(URL)
case localVideoFile(URL)
}
/// Diffable Data Source
public let id: UUID
///
public let source: Source
///
public init(id: UUID = UUID(), source: Source) {
self.id = id
self.source = source
}
///
public var mediaType: MediaType {
switch source {
case .remoteImage, .localImageFile, .image:
return .image
case .remoteVideo, .localVideoFile:
return .video
}
}
///
public var isImage: Bool {
mediaType == .image
}
///
public var isVideo: Bool {
mediaType == .video
}
/// nil
public static func remoteImage(_ urlString: String) -> MediaPreviewItem? {
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteImage($0)) }
}
/// nil
public static func remoteVideo(_ urlString: String) -> MediaPreviewItem? {
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteVideo($0)) }
}
///
public static func localImage(url: URL) -> MediaPreviewItem {
MediaPreviewItem(source: .localImageFile(url))
}
///
public static func image(_ image: UIImage) -> MediaPreviewItem {
MediaPreviewItem(source: .image(image))
}
///
public static func localVideo(url: URL) -> MediaPreviewItem {
MediaPreviewItem(source: .localVideoFile(url))
}
///
public static func clampedStartIndex(_ startIndex: Int, itemCount: Int) -> Int {
guard itemCount > 0 else { return 0 }
return min(max(0, startIndex), itemCount - 1)
}
///
public static func currentItem(in items: [MediaPreviewItem], startIndex: Int) -> MediaPreviewItem? {
guard !items.isEmpty else { return nil }
return items[clampedStartIndex(startIndex, itemCount: items.count)]
}
public static func == (lhs: MediaPreviewItem, rhs: MediaPreviewItem) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
private static func makeRemoteURL(from text: String) -> URL? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return nil }
return url
}
}

View File

@ -0,0 +1,574 @@
//
// MediaPreviewViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
///
class MediaPreviewViewController: UIViewController {
private enum Section {
case main
}
private let items: [MediaPreviewItem]
private let startIndex: Int
private var currentIndex: Int
private var dataSource: UICollectionViewDiffableDataSource<Section, MediaPreviewItem>!
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionLayout())
private let closeButton = UIButton(type: .system)
private let counterLabel = UILabel()
///
init(items: [MediaPreviewItem], startIndex: Int = 0) {
self.items = items
self.startIndex = MediaPreviewItem.clampedStartIndex(startIndex, itemCount: items.count)
self.currentIndex = self.startIndex
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
static func present(from viewController: UIViewController, items: [MediaPreviewItem], startIndex: Int = 0) {
guard !items.isEmpty else { return }
let preview = MediaPreviewViewController(items: items, startIndex: startIndex)
viewController.present(preview, animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupDataSource()
applySnapshot()
registerNotifications()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard collectionView.bounds.width > 0 else { return }
collectionView.collectionViewLayout.invalidateLayout()
collectionView.setContentOffset(
CGPoint(x: CGFloat(currentIndex) * collectionView.bounds.width, y: 0),
animated: false
)
updateVisibleCellsPlayback()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateVisibleCellsPlayback()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pauseVisibleVideos()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func setupUI() {
view.backgroundColor = .black
collectionView.backgroundColor = .black
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceHorizontal = items.count > 1
collectionView.delegate = self
collectionView.register(MediaPreviewImageCell.self, forCellWithReuseIdentifier: MediaPreviewImageCell.reuseIdentifier)
collectionView.register(MediaPreviewVideoCell.self, forCellWithReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier)
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
closeButton.tintColor = .white
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.25)
closeButton.layer.cornerRadius = 18
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
counterLabel.textColor = .white
counterLabel.font = .systemFont(ofSize: 14, weight: .medium)
counterLabel.textAlignment = .center
counterLabel.backgroundColor = UIColor.black.withAlphaComponent(0.35)
counterLabel.layer.cornerRadius = 14
counterLabel.clipsToBounds = true
updateCounter()
view.addSubview(collectionView)
view.addSubview(closeButton)
view.addSubview(counterLabel)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
closeButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(36)
}
counterLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(closeButton)
make.height.equalTo(28)
make.width.greaterThanOrEqualTo(64)
}
}
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, MediaPreviewItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
switch item.source {
case .remoteImage, .localImageFile, .image:
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MediaPreviewImageCell.reuseIdentifier,
for: indexPath
) as? MediaPreviewImageCell else {
return UICollectionViewCell()
}
cell.configure(source: item.source)
return cell
case .remoteVideo, .localVideoFile:
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier,
for: indexPath
) as? MediaPreviewVideoCell else {
return UICollectionViewCell()
}
cell.configure(source: item.source)
cell.setActive(indexPath.item == self?.currentIndex)
return cell
}
}
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, MediaPreviewItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func makeCollectionLayout() -> UICollectionViewLayout {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
return layout
}
private func registerNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
private func updateCurrentIndex() {
guard collectionView.bounds.width > 0 else { return }
let page = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
currentIndex = min(max(0, page), max(0, items.count - 1))
updateCounter()
updateVisibleCellsPlayback()
}
private func updateCounter() {
counterLabel.text = items.isEmpty ? "0/0" : "\(currentIndex + 1)/\(items.count)"
}
private func updateVisibleCellsPlayback() {
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell),
let videoCell = cell as? MediaPreviewVideoCell
else { continue }
videoCell.setActive(indexPath.item == currentIndex && view.window != nil)
}
}
private func pauseVisibleVideos() {
collectionView.visibleCells
.compactMap { $0 as? MediaPreviewVideoCell }
.forEach { $0.setActive(false) }
}
@objc private func closeTapped() {
pauseVisibleVideos()
dismiss(animated: true)
}
@objc private func appDidEnterBackground() {
pauseVisibleVideos()
}
}
extension MediaPreviewViewController: UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
collectionView.bounds.size
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCurrentIndex()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
updateCurrentIndex()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging || scrollView.isDecelerating else { return }
pauseVisibleVideos()
}
}
/// Cell
private final class MediaPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
static let reuseIdentifier = "MediaPreviewImageCell"
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let messageLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
messageLabel.isHidden = true
scrollView.zoomScale = 1
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = scrollView.bounds
}
///
func configure(source: MediaPreviewItem.Source) {
messageLabel.isHidden = true
scrollView.zoomScale = 1
switch source {
case .remoteImage(let url):
imageView.kf.setImage(with: url) { [weak self] result in
if case .failure = result {
self?.showMessage("图片加载失败")
}
}
case .localImageFile(let url):
if let image = UIImage(contentsOfFile: url.path) {
imageView.image = image
} else {
showMessage("图片加载失败")
}
case .image(let image):
imageView.image = image
case .remoteVideo, .localVideoFile:
showMessage("不支持的图片资源")
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
private func setupUI() {
backgroundColor = .black
scrollView.delegate = self
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 3
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textAlignment = .center
messageLabel.isHidden = true
contentView.addSubview(scrollView)
scrollView.addSubview(imageView)
contentView.addSubview(messageLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
messageLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTap)
}
private func showMessage(_ text: String) {
imageView.image = nil
messageLabel.text = text
messageLabel.isHidden = false
}
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > 1 {
scrollView.setZoomScale(1, animated: true)
return
}
let point = gesture.location(in: imageView)
let targetScale = scrollView.maximumZoomScale
let width = scrollView.bounds.width / targetScale
let height = scrollView.bounds.height / targetScale
let rect = CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height)
scrollView.zoom(to: rect, animated: true)
}
}
/// Cell AVPlayer
private final class MediaPreviewVideoCell: UICollectionViewCell, UIGestureRecognizerDelegate {
static let reuseIdentifier = "MediaPreviewVideoCell"
private let playerView = MediaPreviewPlayerView()
private let playButton = MediaPreviewPlaybackButton()
private let messageLabel = UILabel()
private var player: AVPlayer?
private var didReachEnd = false
private var isActive = false
private var isUserPaused = false
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func prepareForReuse() {
super.prepareForReuse()
NotificationCenter.default.removeObserver(self)
player?.pause()
player = nil
playerView.playerLayer.player = nil
didReachEnd = false
isActive = false
isUserPaused = false
messageLabel.isHidden = true
playButton.isHidden = false
}
///
func configure(source: MediaPreviewItem.Source) {
messageLabel.isHidden = true
didReachEnd = false
isUserPaused = false
let url: URL?
switch source {
case .remoteVideo(let remoteURL), .localVideoFile(let remoteURL):
url = remoteURL
case .remoteImage, .localImageFile, .image:
url = nil
}
guard let url else {
showMessage("视频加载失败")
return
}
let player = AVPlayer(url: url)
player.actionAtItemEnd = .pause
self.player = player
playerView.playerLayer.player = player
if let item = player.currentItem {
NotificationCenter.default.addObserver(
self,
selector: #selector(videoDidEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: item
)
}
}
///
func setActive(_ active: Bool) {
isActive = active
guard let player else { return }
guard active else {
player.pause()
playButton.isHidden = !didReachEnd
return
}
if didReachEnd {
showPlaybackButton(systemName: "pause.fill")
} else if isUserPaused {
showPlaybackButton(systemName: "play.fill")
player.pause()
} else {
playButton.isHidden = true
player.play()
}
}
private func setupUI() {
backgroundColor = .black
playerView.playerLayer.videoGravity = .resizeAspect
playButton.setSymbol(named: "pause.fill")
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textAlignment = .center
messageLabel.isHidden = true
contentView.addSubview(playerView)
contentView.addSubview(playButton)
contentView.addSubview(messageLabel)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(96)
}
messageLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(videoTapped))
tapGesture.delegate = self
contentView.addGestureRecognizer(tapGesture)
}
private func showMessage(_ text: String) {
messageLabel.text = text
messageLabel.isHidden = false
playButton.isHidden = true
}
private func showPlaybackButton(systemName: String) {
playButton.setSymbol(named: systemName)
playButton.isHidden = false
}
private func startPlaybackFromCurrentPosition() {
guard let player else { return }
if didReachEnd {
player.seek(to: .zero)
didReachEnd = false
}
isUserPaused = false
playButton.isHidden = true
player.play()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard touch.view?.isDescendant(of: playButton) != true else { return false }
return true
}
@objc private func playTapped() {
startPlaybackFromCurrentPosition()
}
@objc private func videoTapped() {
guard isActive, let player else { return }
if didReachEnd || isUserPaused {
startPlaybackFromCurrentPosition()
return
}
isUserPaused = true
player.pause()
showPlaybackButton(systemName: "play.fill")
}
@objc private func videoDidEnd() {
didReachEnd = true
isUserPaused = false
showPlaybackButton(systemName: "pause.fill")
}
}
/// AVPlayerLayer
private final class MediaPreviewPlayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
///
private final class MediaPreviewPlaybackButton: UIButton {
private let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
gradientLayer.cornerRadius = bounds.width / 2
layer.cornerRadius = bounds.width / 2
}
/// SF Symbol
func setSymbol(named systemName: String) {
let configuration = UIImage.SymbolConfiguration(pointSize: 42, weight: .semibold)
setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
}
private func setupUI() {
tintColor = .white
backgroundColor = UIColor.white.withAlphaComponent(0.08)
adjustsImageWhenHighlighted = false
layer.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.28
layer.shadowRadius = 18
layer.shadowOffset = CGSize(width: 0, height: 10)
gradientLayer.colors = [
UIColor.white.withAlphaComponent(0.32).cgColor,
UIColor.black.withAlphaComponent(0.38).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0.18, y: 0.12)
gradientLayer.endPoint = CGPoint(x: 0.88, y: 0.92)
layer.insertSublayer(gradientLayer, at: 0)
setSymbol(named: "pause.fill")
}
}