mirror of
https://github.com/yattee/yattee.git
synced 2025-11-22 06:31:26 +00:00
Fixed issues with thumbnails being stretched vertically and layout jumping during image loading: - Simplified VideoCellThumbnail to always use 16:9 aspect ratio with .fill mode - Added matching 16:9 aspect ratio to placeholder with .fill mode to prevent layout shifts - Removed quality-based aspect ratio selection (4:3 vs 16:9) in favor of consistent 16:9 - Ensures thumbnails maintain proper proportions on both iOS and macOS This provides consistent sizing across platforms and eliminates the jump when images finish loading.
497 lines
16 KiB
Swift
497 lines
16 KiB
Swift
import CoreMedia
|
|
import Defaults
|
|
import SDWebImageSwiftUI
|
|
import SwiftUI
|
|
|
|
struct VideoCell: View {
|
|
var id: String?
|
|
private var video: Video
|
|
private var watch: Watch?
|
|
|
|
@Environment(\.horizontalCells) private var horizontalCells
|
|
@Environment(\.inChannelView) private var inChannelView
|
|
@Environment(\.navigationStyle) private var navigationStyle
|
|
|
|
#if os(iOS)
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
#endif
|
|
|
|
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
|
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
|
@Default(.roundedThumbnails) private var roundedThumbnails
|
|
@Default(.saveHistory) private var saveHistory
|
|
@Default(.showWatchingProgress) private var showWatchingProgress
|
|
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
|
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
|
|
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
|
@Default(.showChannelAvatarInVideosListing) private var showChannelAvatarInVideosListing
|
|
|
|
private var navigation: NavigationModel { .shared }
|
|
private var player: PlayerModel { .shared }
|
|
|
|
init(id: String? = nil, video: Video, watch: Watch? = nil) {
|
|
self.id = id
|
|
self.video = video
|
|
self.watch = watch
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: playAction) {
|
|
content
|
|
#if os(tvOS)
|
|
.frame(width: 550, height: channelOnThumbnail ? 446 : 480)
|
|
#endif
|
|
}
|
|
.opacity(contentOpacity)
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.contentShape(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
|
.contextMenu {
|
|
VideoContextMenuView(video: video)
|
|
}
|
|
.id(id ?? video.videoID)
|
|
}
|
|
|
|
private var thumbnailRoundingCornerRadius: Double {
|
|
#if os(tvOS)
|
|
return Double(12)
|
|
#else
|
|
return Double(roundedThumbnails ? 12 : 0)
|
|
#endif
|
|
}
|
|
|
|
private func playAction() {
|
|
DispatchQueue.main.async {
|
|
guard video.videoID != Video.fixtureID else {
|
|
return
|
|
}
|
|
|
|
if player.musicMode {
|
|
player.toggleMusicMode()
|
|
}
|
|
|
|
if watchingNow {
|
|
if !player.playingInPictureInPicture {
|
|
player.show()
|
|
}
|
|
|
|
if !playNowContinues {
|
|
player.backend.seek(to: .zero, seekType: .userInteracted)
|
|
}
|
|
|
|
player.play()
|
|
|
|
return
|
|
}
|
|
|
|
var playAt: CMTime?
|
|
|
|
if saveHistory,
|
|
playNowContinues,
|
|
!watch.isNil,
|
|
!watch!.finished
|
|
{
|
|
playAt = .secondsInDefaultTimescale(watch!.stoppedAt)
|
|
}
|
|
|
|
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
|
|
|
player.play(video, at: playAt)
|
|
}
|
|
}
|
|
|
|
private var playNowContinues: Bool {
|
|
watchedVideoPlayNowBehavior == .continue
|
|
}
|
|
|
|
private var finished: Bool {
|
|
watch?.finished ?? false
|
|
}
|
|
|
|
private var watchingNow: Bool {
|
|
player.currentVideo == video
|
|
}
|
|
|
|
private var content: some View {
|
|
VStack {
|
|
#if os(iOS)
|
|
if verticalSizeClass == .compact, !horizontalCells {
|
|
horizontalRow
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
verticalRow
|
|
}
|
|
#else
|
|
verticalRow
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.background(Color.secondaryBackground)
|
|
#endif
|
|
}
|
|
|
|
private var contentOpacity: Double {
|
|
guard saveHistory,
|
|
!watch.isNil,
|
|
watchedVideoStyle.isDecreasingOpacity
|
|
else {
|
|
return 1
|
|
}
|
|
|
|
return watch!.finished ? 0.5 : 1
|
|
}
|
|
|
|
#if os(iOS)
|
|
private var horizontalRow: some View {
|
|
HStack(alignment: .top, spacing: 2) {
|
|
Section {
|
|
#if os(tvOS)
|
|
thumbnailImage
|
|
#else
|
|
thumbnail
|
|
#endif
|
|
}
|
|
.frame(maxWidth: 320)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
videoDetail(video.displayTitle, lineLimit: 5)
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack(spacing: Constants.channelDetailsStackSpacing) {
|
|
if !inChannelView,
|
|
showChannelAvatarInVideosListing,
|
|
video != .fixture
|
|
{
|
|
ChannelLinkView(channel: video.channel) {
|
|
if showChannelAvatarInVideosListing {
|
|
ChannelAvatarView(channel: video.channel)
|
|
.frame(width: Constants.channelThumbnailSize, height: Constants.channelThumbnailSize)
|
|
} else {
|
|
channelLabel(badge: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !channelOnThumbnail,
|
|
!inChannelView
|
|
{
|
|
ChannelLinkView(channel: video.channel) {
|
|
channelLabel(badge: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
if additionalDetailsAvailable {
|
|
Spacer()
|
|
|
|
HStack(spacing: 15) {
|
|
if let date = video.publishedDate {
|
|
VStack {
|
|
Image(systemName: "calendar")
|
|
.frame(height: 15)
|
|
Text(date)
|
|
}
|
|
}
|
|
|
|
if video.views > 0 {
|
|
VStack {
|
|
Image(systemName: "eye")
|
|
.frame(height: 15)
|
|
Text(video.viewsCount!)
|
|
}
|
|
}
|
|
|
|
if !timeOnThumbnail, let time = videoDuration {
|
|
VStack {
|
|
Image(systemName: "clock")
|
|
.frame(height: 15)
|
|
Text(time)
|
|
}
|
|
}
|
|
}
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(minHeight: 180)
|
|
|
|
#if os(tvOS)
|
|
if let time = videoDuration || video.live || video.upcoming {
|
|
Spacer()
|
|
|
|
VStack {
|
|
Spacer()
|
|
|
|
if let time = videoDuration {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "clock")
|
|
Text(time)
|
|
.fontWeight(.bold)
|
|
}
|
|
.foregroundColor(.secondary)
|
|
} else if video.live {
|
|
DetailBadge(text: "Live", style: .outstanding)
|
|
} else if video.upcoming {
|
|
DetailBadge(text: "Upcoming", style: .informational)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.lineLimit(1)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var videoDuration: String? {
|
|
let length = video.length.isZero ? watch?.videoDuration : video.length
|
|
return length?.formattedAsPlaybackTime()
|
|
}
|
|
|
|
private var verticalRow: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
thumbnail
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Group {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
videoDetail(video.displayTitle, lineLimit: 2)
|
|
#if os(tvOS)
|
|
.frame(minHeight: 60, alignment: .top)
|
|
#elseif os(macOS)
|
|
.frame(minHeight: 35, alignment: .top)
|
|
#else
|
|
.frame(minHeight: 43, alignment: .top)
|
|
#endif
|
|
if !channelOnThumbnail, !inChannelView {
|
|
ChannelLinkView(channel: video.channel) {
|
|
HStack(spacing: Constants.channelDetailsStackSpacing) {
|
|
if video != .fixture, showChannelAvatarInVideosListing {
|
|
ChannelAvatarView(channel: video.channel)
|
|
.frame(width: Constants.channelThumbnailSize, height: Constants.channelThumbnailSize)
|
|
}
|
|
|
|
channelLabel(badge: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
#if os(tvOS)
|
|
.frame(minHeight: channelOnThumbnail ? 80 : 120, alignment: .top)
|
|
#elseif os(macOS)
|
|
.frame(minHeight: channelOnThumbnail ? 52 : 75, alignment: .top)
|
|
#else
|
|
.frame(minHeight: channelOnThumbnail ? 50 : 70, alignment: .top)
|
|
#endif
|
|
.padding(.bottom, 4)
|
|
|
|
HStack(spacing: 8) {
|
|
if channelOnThumbnail,
|
|
!inChannelView,
|
|
video.channel.thumbnailURLOrCached != nil,
|
|
video != .fixture
|
|
{
|
|
ChannelLinkView(channel: video.channel) {
|
|
ChannelAvatarView(channel: video.channel)
|
|
.frame(width: Constants.channelThumbnailSize, height: Constants.channelThumbnailSize)
|
|
}
|
|
}
|
|
|
|
if let date = video.publishedDate {
|
|
HStack(spacing: 2) {
|
|
Text(date)
|
|
.allowsTightening(true)
|
|
}
|
|
}
|
|
|
|
if video.views > 0 {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "eye")
|
|
Text(video.viewsCount!)
|
|
}
|
|
}
|
|
|
|
if let time, !timeOnThumbnail {
|
|
Spacer()
|
|
|
|
HStack(spacing: 2) {
|
|
Text(time)
|
|
}
|
|
}
|
|
}
|
|
.lineLimit(1)
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity, minHeight: 35, alignment: .topLeading)
|
|
#if os(tvOS)
|
|
.padding(.bottom, 10)
|
|
#endif
|
|
}
|
|
.padding(.top, 4)
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading)
|
|
#if os(tvOS)
|
|
.padding(.horizontal, horizontalCells ? 10 : 20)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func channelLabel(badge: Bool = true) -> some View {
|
|
if badge {
|
|
DetailBadge(text: video.author, style: .prominent)
|
|
.foregroundColor(.primary)
|
|
} else {
|
|
Text(verbatim: video.channel.name)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
private var additionalDetailsAvailable: Bool {
|
|
video.publishedDate != nil || video.views != 0 ||
|
|
(!timeOnThumbnail && !videoDuration.isNil)
|
|
}
|
|
|
|
private var thumbnail: some View {
|
|
ZStack(alignment: .leading) {
|
|
ZStack(alignment: .bottomLeading) {
|
|
thumbnailImage
|
|
|
|
ProgressView(value: watch?.progress ?? 0, total: 100)
|
|
.progressViewStyle(LinearProgressViewStyle(tint: Color("AppRedColor")))
|
|
#if os(tvOS)
|
|
.padding(.horizontal, 16)
|
|
#else
|
|
.padding(.horizontal, 10)
|
|
#endif
|
|
#if os(macOS)
|
|
.offset(x: 0, y: 4)
|
|
#else
|
|
.offset(x: 0, y: -3)
|
|
#endif
|
|
.opacity(watch?.isShowingProgress ?? false ? 1 : 0)
|
|
}
|
|
|
|
VStack {
|
|
HStack(alignment: .top) {
|
|
if saveHistory,
|
|
watchedVideoStyle.isShowingBadge
|
|
{
|
|
WatchView(watch: watch, videoID: video.videoID, duration: video.length)
|
|
}
|
|
|
|
if video.live {
|
|
DetailBadge(text: "Live", style: .outstanding)
|
|
} else if video.upcoming {
|
|
DetailBadge(text: "Upcoming", style: .informational)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if channelOnThumbnail, !inChannelView {
|
|
ChannelLinkView(channel: video.channel) {
|
|
channelLabel()
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.padding(16)
|
|
#else
|
|
.padding(10)
|
|
#endif
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
PlayingIndicatorView(video: video, height: 20)
|
|
.frame(width: 15, alignment: .trailing)
|
|
.padding(.trailing, 3)
|
|
HStack {
|
|
Spacer()
|
|
|
|
if timeOnThumbnail,
|
|
!video.live,
|
|
let time
|
|
{
|
|
DetailBadge(text: time, style: .prominent)
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.padding(16)
|
|
#else
|
|
.padding(10)
|
|
#endif
|
|
}
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
private var thumbnailImage: some View {
|
|
VideoCellThumbnail(video: video)
|
|
#if os(tvOS)
|
|
.frame(minHeight: 320)
|
|
#endif
|
|
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
|
}
|
|
|
|
private var time: String? {
|
|
guard var videoTime = videoDuration else {
|
|
return nil
|
|
}
|
|
|
|
if !saveHistory || !showWatchingProgress || watch?.finished ?? false {
|
|
return videoTime
|
|
}
|
|
|
|
if let stoppedAt = watch?.stoppedAt,
|
|
stoppedAt.isFinite,
|
|
let stoppedAtFormatted = stoppedAt.formattedAsPlaybackTime()
|
|
{
|
|
if (watch?.videoDuration ?? 0) > 0 {
|
|
videoTime = watch!.videoDuration.formattedAsPlaybackTime() ?? "?"
|
|
}
|
|
return "\(stoppedAtFormatted) / \(videoTime)"
|
|
}
|
|
|
|
return videoTime
|
|
}
|
|
|
|
private func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
|
Text(verbatim: text)
|
|
.fontWeight(.bold)
|
|
.lineLimit(lineLimit)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
|
|
struct VideoCellThumbnail: View {
|
|
let video: Video
|
|
private var thumbnails: ThumbnailsModel { .shared }
|
|
|
|
var body: some View {
|
|
let (url, _) = thumbnails.best(video)
|
|
|
|
ThumbnailView(url: url)
|
|
.aspectRatio(Constants.aspectRatio16x9, contentMode: .fill)
|
|
}
|
|
}
|
|
|
|
struct VideoCell_Preview: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
VideoCell(video: Video.fixture)
|
|
}
|
|
#if os(macOS)
|
|
.frame(maxWidth: 300, maxHeight: 250)
|
|
#elseif os(iOS)
|
|
.frame(maxWidth: 600, maxHeight: 200)
|
|
#endif
|
|
.injectFixtureEnvironmentObjects()
|
|
}
|
|
}
|