diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index bba20062..7e71208f 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -596,6 +596,8 @@ final class AVPlayerBackend: PlayerBackend { if self.controlsUpdates { self.updateControls() } + + self.model.updateTime(self.currentTime!) } } diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 773251d8..c8457946 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -182,13 +182,21 @@ final class MPVBackend: PlayerBackend { } init() { + // swiftlint:disable shorthand_optional_binding clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in - self?.getTimeUpdates() + guard let self = self, self.model.activeBackend == .mpv else { + return + } + self.getTimeUpdates() } networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in - self?.updateNetworkState() + guard let self = self, self.model.activeBackend == .mpv else { + return + } + self.updateNetworkState() } + // swiftlint:enable shorthand_optional_binding } typealias AreInIncreasingOrder = (Stream, Stream) -> Bool @@ -432,6 +440,8 @@ final class MPVBackend: PlayerBackend { timeObserverThrottle.execute { self.model.updateWatch(time: self.currentTime) } + + self.model.updateTime(self.currentTime!) } private func stopClientUpdates() { diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index d9835211..d91ae3eb 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -131,6 +131,8 @@ final class PlayerModel: ObservableObject { @Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen #endif + @Published var currentChapterIndex: Int? + var accounts: AccountsModel { .shared } var comments: CommentsModel { .shared } var controls: PlayerControlsModel { .shared } @@ -1112,4 +1114,36 @@ final class PlayerModel: ObservableObject { onPlayStream.forEach { $0(stream) } onPlayStream.removeAll() } + + func updateTime(_ cmTime: CMTime) { + let time = CMTimeGetSeconds(cmTime) + let newChapterIndex = chapterForTime(time) + if currentChapterIndex != newChapterIndex { + DispatchQueue.main.async { + self.currentChapterIndex = newChapterIndex + } + } + } + + private func chapterForTime(_ time: Double) -> Int? { + guard let chapters = self.videoForDisplay?.chapters else { + return nil + } + + for (index, chapter) in chapters.enumerated() { + let nextChapterStartTime = index < (chapters.count - 1) ? chapters[index + 1].start : nil + + if let nextChapterStart = nextChapterStartTime { + if time >= chapter.start, time < nextChapterStart { + return index + } + } else { + if time >= chapter.start { + return index + } + } + } + + return nil + } } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index f1ed24b6..f6e86d08 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -265,6 +265,7 @@ extension Defaults.Keys { static let hideWatched = Key("hideWatched", default: false) static let showInspector = Key("showInspector", default: .onlyLocal) static let showChapters = Key("showChapters", default: true) + static let expandChapters = Key("expandChapters", default: true) static let showRelated = Key("showRelated", default: true) static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: []) } diff --git a/Shared/Player/Video Details/ChapterView.swift b/Shared/Player/Video Details/ChapterView.swift index 9ee184e4..8a5fc0d2 100644 --- a/Shared/Player/Video Details/ChapterView.swift +++ b/Shared/Player/Video Details/ChapterView.swift @@ -2,28 +2,85 @@ import Foundation import SDWebImageSwiftUI import SwiftUI -struct ChapterView: View { - var chapter: Chapter +#if !os(tvOS) + struct ChapterView: View { + var chapter: Chapter - var player = PlayerModel.shared + var chapterIndex: Int + @ObservedObject private var player = PlayerModel.shared - var body: some View { - Button { - player.backend.seek(to: chapter.start, seekType: .userInteracted) - } label: { - Group { - #if os(tvOS) - horizontalChapter - #else - verticalChapter - #endif - } - .contentShape(Rectangle()) + var isCurrentChapter: Bool { + player.currentChapterIndex == chapterIndex + } + + var body: some View { + Button(action: { + player.backend.seek(to: chapter.start, seekType: .userInteracted) + }) { + Group { + verticalChapter + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + var verticalChapter: some View { + VStack(spacing: 12) { + if !chapter.image.isNil { + smallImage(chapter) + } + VStack(alignment: .leading, spacing: 4) { + Text(chapter.title) + .lineLimit(3) + .multilineTextAlignment(.leading) + .font(.headline) + .foregroundColor(isCurrentChapter ? Color("AppRedColor") : .primary) + Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "") + .font(.system(.subheadline).monospacedDigit()) + .foregroundColor(.secondary) + } + .frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading) + } + } + + @ViewBuilder func smallImage(_ chapter: Chapter) -> some View { + WebImage(url: chapter.image, options: [.lowPriority]) + .resizable() + .placeholder { + ProgressView() + } + .indicator(.activity) + .frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight) + + .mask(RoundedRectangle(cornerRadius: 6)) + } + + static var thumbnailWidth: Double { + 250 + } + + static var thumbnailHeight: Double { + thumbnailWidth / 1.7777 } - .buttonStyle(.plain) } - #if os(tvOS) +#else + struct ChapterViewTVOS: View { + var chapter: Chapter + var player = PlayerModel.shared + + var body: some View { + Button { + player.backend.seek(to: chapter.start, seekType: .userInteracted) + } label: { + Group { + horizontalChapter + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } var horizontalChapter: some View { HStack(spacing: 12) { @@ -41,53 +98,36 @@ struct ChapterView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - #else - var verticalChapter: some View { - VStack(spacing: 12) { - if !chapter.image.isNil { - smallImage(chapter) + + @ViewBuilder func smallImage(_ chapter: Chapter) -> some View { + WebImage(url: chapter.image, options: [.lowPriority]) + .resizable() + .placeholder { + ProgressView() } - VStack(alignment: .leading, spacing: 4) { - Text(chapter.title) - .lineLimit(2) - .multilineTextAlignment(.leading) - .font(.headline) - Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "") - .font(.system(.subheadline).monospacedDigit()) - .foregroundColor(.secondary) - } - .frame(maxWidth: Self.thumbnailWidth, alignment: .leading) - } + .indicator(.activity) + .frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight) + .mask(RoundedRectangle(cornerRadius: 12)) } - #endif - @ViewBuilder func smallImage(_ chapter: Chapter) -> some View { - WebImage(url: chapter.image, options: [.lowPriority]) - .resizable() - .placeholder { - ProgressView() - } - .indicator(.activity) - .frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight) - #if os(tvOS) - .mask(RoundedRectangle(cornerRadius: 12)) - #else - .mask(RoundedRectangle(cornerRadius: 6)) - #endif - } + static var thumbnailWidth: Double { + 250 + } - static var thumbnailWidth: Double { - 250 + static var thumbnailHeight: Double { + thumbnailWidth / 1.7777 + } } - - static var thumbnailHeight: Double { - thumbnailWidth / 1.7777 - } -} +#endif struct ChapterView_Preview: PreviewProvider { static var previews: some View { - ChapterView(chapter: .init(title: "Chapter", start: 30)) - .injectFixtureEnvironmentObjects() + #if os(tvOS) + ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30)) + .injectFixtureEnvironmentObjects() + #else + ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0) + .injectFixtureEnvironmentObjects() + #endif } } diff --git a/Shared/Player/Video Details/ChaptersView.swift b/Shared/Player/Video Details/ChaptersView.swift index 1bf266a9..65baa874 100644 --- a/Shared/Player/Video Details/ChaptersView.swift +++ b/Shared/Player/Video Details/ChaptersView.swift @@ -4,6 +4,7 @@ import SwiftUI struct ChaptersView: View { @ObservedObject private var player = PlayerModel.shared + @Binding var expand: Bool var chapters: [Chapter] { player.videoForDisplay?.chapters ?? [] @@ -15,45 +16,71 @@ struct ChaptersView: View { var body: some View { if !chapters.isEmpty { - #if os(tvOS) - List { - Section { - ForEach(chapters) { chapter in - ChapterView(chapter: chapter) - } - } - .listRowBackground(Color.clear) - } - .listStyle(.plain) - #else - if chaptersHaveImages { - ScrollView(.horizontal) { - LazyHStack(spacing: 20) { + if chaptersHaveImages { + #if os(tvOS) + List { + Section { ForEach(chapters) { chapter in - ChapterView(chapter: chapter) + ChapterViewTVOS(chapter: chapter) } } - .padding(.horizontal, 15) + .listRowBackground(Color.clear) } - .frame(minHeight: ChapterView.thumbnailHeight + 100) - } else { + .listStyle(.plain) + #else + ScrollView(.horizontal) { + LazyHStack(spacing: 20) { chapterViews(for: chapters[...]) }.padding(.horizontal, 15) + } + #endif + } else if expand { + #if os(tvOS) Section { ForEach(chapters) { chapter in - ChapterView(chapter: chapter) + ChapterViewTVOS(chapter: chapter) } } - .padding(.horizontal) - } - #endif - } else { - NoCommentsView(text: "No chapters information available".localized(), systemImage: "xmark.circle.fill") + #else + Section { chapterViews(for: chapters[...]) }.padding(.horizontal) + #endif + } else { + #if os(iOS) + Button(action: { + self.expand.toggle() + }) { + Section { + chapterViews(for: chapters.prefix(3), opacity: 0.3, clickable: false) + }.padding(.horizontal) + } + #elseif os(macOS) + Section { + chapterViews(for: chapters.prefix(3), opacity: 0.3, clickable: false) + }.padding(.horizontal) + #else + Section { + ForEach(chapters) { chapter in + ChapterViewTVOS(chapter: chapter) + } + } + #endif + } } } + + #if !os(tvOS) + private func chapterViews(for chaptersToShow: ArraySlice, opacity: Double = 1.0, clickable: Bool = true) -> some View { + ForEach(Array(chaptersToShow.indices), id: \.self) { index in + let chapter = chaptersToShow[index] + ChapterView(chapter: chapter, chapterIndex: index) + .opacity(index == 0 ? 1.0 : opacity) + .allowsHitTesting(clickable) + } + } + #endif } struct ChaptersView_Previews: PreviewProvider { static var previews: some View { - ChaptersView() + ChaptersView(expand: .constant(false)) .injectFixtureEnvironmentObjects() } } diff --git a/Shared/Player/Video Details/VideoDetails.swift b/Shared/Player/Video Details/VideoDetails.swift index 428580c6..1da9b6d8 100644 --- a/Shared/Player/Video Details/VideoDetails.swift +++ b/Shared/Player/Video Details/VideoDetails.swift @@ -169,6 +169,7 @@ struct VideoDetails: View { @State private var subscriptionToggleButtonDisabled = false @State private var page = DetailsPage.info @State private var descriptionExpanded = false + @State private var chaptersExpanded = false @Environment(\.navigationStyle) private var navigationStyle #if os(iOS) @@ -190,6 +191,7 @@ struct VideoDetails: View { @Default(.showScrollToTopInComments) private var showScrollToTopInComments #endif @Default(.expandVideoDescription) private var expandVideoDescription + @Default(.expandChapters) private var expandChapters var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -245,6 +247,7 @@ struct VideoDetails: View { .background(colorScheme == .dark ? Color.black : .white) .onAppear { descriptionExpanded = expandVideoDescription + chaptersExpanded = expandChapters } } @@ -320,7 +323,7 @@ struct VideoDetails: View { !video.chapters.isEmpty { Section(header: chaptersHeader) { - ChaptersView() + ChaptersView(expand: $chaptersExpanded) } } @@ -440,11 +443,48 @@ struct VideoDetails: View { #endif } + var chaptersHaveImages: Bool { + player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false + } + var chaptersHeader: some View { - Text("Chapters".localized()) - .padding(.horizontal) - .font(.caption) - .foregroundColor(.secondary) + Group { + if !chaptersHaveImages { + #if canImport(UIKit) + Button(action: { + chaptersExpanded.toggle() + }) { + HStack { + Text("Chapters".localized()) + Spacer() + Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down") + .imageScale(.small) + } + .padding(.horizontal) + .font(.caption) + .foregroundColor(.secondary) + } + #elseif canImport(AppKit) + HStack { + Text("Chapters".localized()) + Spacer() + Button(action: { chaptersExpanded.toggle() }) { + Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down") + .imageScale(.small) + } + } + .padding(.horizontal) + .font(.caption) + .foregroundColor(.secondary) + #endif + } else { + // No button, just the title when there are images + Text("Chapters".localized()) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + } } } diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index 37c3d701..0ae8defd 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -32,6 +32,7 @@ struct PlayerSettings: View { @Default(.showInspector) private var showInspector @Default(.showChapters) private var showChapters + @Default(.expandChapters) private var expandChapters @Default(.showRelated) private var showRelated @ObservedObject private var accounts = AccountsModel.shared @@ -80,6 +81,7 @@ struct PlayerSettings: View { expandVideoDescriptionToggle collapsedLineDescriptionStepper showChaptersToggle + expandChaptersToggle showRelatedToggle #if os(macOS) HStack { @@ -282,7 +284,13 @@ struct PlayerSettings: View { } private var showChaptersToggle: some View { - Toggle("Chapters", isOn: $showChapters) + Toggle("Chapters (if available)", isOn: $showChapters) + } + + private var expandChaptersToggle: some View { + Toggle("Open vertical chapters expanded", isOn: $expandChapters) + .disabled(!showChapters) + .foregroundColor(showChapters ? .primary : .secondary) } private var showRelatedToggle: some View { diff --git a/tvOS/NowPlayingView.swift b/tvOS/NowPlayingView.swift index 1502790f..14f3976a 100644 --- a/tvOS/NowPlayingView.swift +++ b/tvOS/NowPlayingView.swift @@ -130,7 +130,7 @@ struct NowPlayingView: View { } else { Section(header: Text("Chapters")) { ForEach(video.chapters) { chapter in - ChapterView(chapter: chapter) + ChapterViewTVOS(chapter: chapter) .padding(.horizontal, 40) } }