diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index a619bf9c..b877197e 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -112,6 +112,7 @@ extension BaseItemDto { response: response, audioStreams: audioStreams, subtitleStreams: subtitleStreams, + chapters: modifiedSelfItem.chapters ?? [], selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, subtitlesEnabled: subtitlesEnabled, diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index bef3e514..8c353cb7 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -312,4 +312,22 @@ public extension BaseItemDto { dateFormatter.dateStyle = .medium return dateFormatter.string(from: premiereDate) } + + // MARK: Chapter Images + + func getChapterImage(maxWidth: Int) -> [URL] { + guard let chapters = chapters, !chapters.isEmpty else { return [] } + + var chapterImageURLs: [URL] = [] + + for chapterIndex in 0 ..< chapters.count { + let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", + imageType: .chapter, + maxWidth: maxWidth, + imageIndex: chapterIndex).URLString + chapterImageURLs.append(URL(string: urlString)!) + } + + return chapterImageURLs + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift new file mode 100644 index 00000000..5d5fb50e --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift @@ -0,0 +1,44 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension ChapterInfo { + + var timestampLabel: String { + let seconds = (startPositionTicks ?? 0) / 10_000_000 + return seconds.toReadableString() + } +} + +extension Int64 { + + func toReadableString() -> String { + + let s = Int(self) % 60 + let mn = (Int(self) / 60) % 60 + let hr = (Int(self) / 3600) + + var final = "" + + if hr != 0 { + final += "\(hr):" + } + + if mn != 0 { + final += String(format: "%0.2d:", mn) + } else { + final += "00:" + } + + final += String(format: "%0.2d", s) + + return final + } +} diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index e62b4404..63840d3d 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -50,6 +50,8 @@ internal enum L10n { internal static let changeServer = L10n.tr("Localizable", "changeServer") /// Channels internal static let channels = L10n.tr("Localizable", "channels") + /// Chapters + internal static let chapters = L10n.tr("Localizable", "chapters") /// Cinematic Views internal static let cinematicViews = L10n.tr("Localizable", "cinematicViews") /// Closed Captions diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index c23d6fe0..1556f745 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Algorithms import Combine import Defaults import Foundation @@ -110,6 +111,7 @@ final class VideoPlayerViewModel: ViewModel { let transcodedStreamURL: URL? let audioStreams: [MediaStream] let subtitleStreams: [MediaStream] + let chapters: [ChapterInfo] let overlayType: OverlayType let jumpGesturesEnabled: Bool let resumeOffset: Bool @@ -155,6 +157,22 @@ final class VideoPlayerViewModel: ViewModel { subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex }) } + var currentChapter: ChapterInfo? { + + let chapterPairs = chapters.adjacentPairs().map { ($0, $1) } + let chapterRanges = chapterPairs.map { ($0.startPositionTicks ?? 0, ($1.startPositionTicks ?? 1) - 1) } + + for chapterRangeIndex in 0 ..< chapterRanges.count { + if chapterRanges[chapterRangeIndex].0 <= currentSecondTicks && + currentSecondTicks < chapterRanges[chapterRangeIndex].1 + { + return chapterPairs[chapterRangeIndex].0 + } + } + + return nil + } + // Necessary PassthroughSubject to capture manual scrubbing from sliders let sliderScrubbingSubject = PassthroughSubject() @@ -174,6 +192,7 @@ final class VideoPlayerViewModel: ViewModel { response: PlaybackInfoResponse, audioStreams: [MediaStream], subtitleStreams: [MediaStream], + chapters: [ChapterInfo], selectedAudioStreamIndex: Int, selectedSubtitleStreamIndex: Int, subtitlesEnabled: Bool, @@ -195,6 +214,7 @@ final class VideoPlayerViewModel: ViewModel { self.response = response self.audioStreams = audioStreams self.subtitleStreams = subtitleStreams + self.chapters = chapters self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex self.subtitlesEnabled = subtitlesEnabled diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift similarity index 100% rename from Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift rename to Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift similarity index 100% rename from Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift rename to Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift similarity index 100% rename from Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift rename to Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 827d3b35..b7273063 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -239,6 +239,10 @@ C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; }; + E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; + E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; + E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */; }; E103A6A3278A7EC400820EC7 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */; }; @@ -648,6 +652,8 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; + E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; + E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICinematicBackgroundView.swift; sourceTree = ""; }; E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicBackgroundView.swift; sourceTree = ""; }; @@ -798,6 +804,7 @@ E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, E1361DA7278FA7A300BEC523 /* NukeUI in Frameworks */, 53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */, + E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, E10EAA4D277BB716000269ED /* Sliders in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */, @@ -845,7 +852,7 @@ children = ( E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, E178859C2780F5300094FBCF /* tvOSSLider */, - E17885A7278130690094FBCF /* tvOSOverlay */, + E17885A7278130690094FBCF /* Overlays */, E1C812C8277AE40900918266 /* VideoPlayerView.swift */, E1384943278036C70024FB48 /* VLCPlayerViewController.swift */, ); @@ -1343,6 +1350,15 @@ path = Pods; sourceTree = ""; }; + E1002B692793E12E00E47059 /* Overlays */ = { + isa = PBXGroup; + children = ( + E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */, + ); + path = Overlays; + sourceTree = ""; + }; E103A6A1278A7EB500820EC7 /* HomeCinematicView */ = { isa = PBXGroup; children = ( @@ -1500,14 +1516,14 @@ path = tvOSSLider; sourceTree = ""; }; - E17885A7278130690094FBCF /* tvOSOverlay */ = { + E17885A7278130690094FBCF /* Overlays */ = { isa = PBXGroup; children = ( E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */, E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, ); - path = tvOSOverlay; + path = Overlays; sourceTree = ""; }; E18845FA26DEACBE00B0C5B7 /* Portrait */ = { @@ -1543,7 +1559,7 @@ isa = PBXGroup; children = ( E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, - E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E1002B692793E12E00E47059 /* Overlays */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); @@ -1567,6 +1583,7 @@ E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */, E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, + E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */, E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, @@ -1754,6 +1771,7 @@ E10EAA4C277BB716000269ED /* Sliders */, E1AE8E7B2789135A00FBDDAA /* Nuke */, E1361DA6278FA7A300BEC523 /* NukeUI */, + E1002B672793CFBA00E47059 /* Algorithms */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -1847,14 +1865,15 @@ E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */, E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */, E1361DA5278FA7A300BEC523 /* XCRemoteSwiftPackageReference "NukeUI" */, + E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 5377CBF0263B596A003A4E83 /* Swiftfin iOS */, - 5358705F2669D21600D05A09 /* Swiftfin tvOS */, 628B951F2670CABD0091AF3B /* Swiftfin Widget */, + 5358705F2669D21600D05A09 /* Swiftfin tvOS */, ); }; /* End PBXProject section */ @@ -2138,6 +2157,7 @@ E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, + E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, E103A6A5278A82E500820EC7 /* HomeCinematicView.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, @@ -2339,6 +2359,7 @@ E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */, E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, + E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, @@ -2358,6 +2379,7 @@ E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */, E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, + E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -2792,7 +2814,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2829,7 +2851,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2860,7 +2882,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2887,7 +2909,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2995,6 +3017,14 @@ kind = branch; }; }; + E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift"; @@ -3117,6 +3147,11 @@ package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; + E1002B672793CFBA00E47059 /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; E10EAA44277BB646000269ED /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; diff --git a/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved index 00182b81..8e52e799 100644 --- a/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -109,6 +109,15 @@ "version": "2.0.3" } }, + { + "package": "swift-algorithms", + "repositoryURL": "https://github.com/apple/swift-algorithms.git", + "state": { + "branch": null, + "revision": "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version": "1.0.0" + } + }, { "package": "swift-log", "repositoryURL": "https://github.com/apple/swift-log.git", @@ -118,6 +127,15 @@ "version": "1.4.2" } }, + { + "package": "swift-numerics", + "repositoryURL": "https://github.com/apple/swift-numerics", + "state": { + "branch": null, + "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", + "version": "1.0.2" + } + }, { "package": "Introspect", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift new file mode 100644 index 00000000..f0887f6b --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift @@ -0,0 +1,103 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct VLCPlayerChapterOverlayView: View { + + @ObservedObject + var viewModel: VideoPlayerViewModel + private let chapterImages: [URL] + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) + } + + @ViewBuilder + private var mainBody: some View { + ZStack(alignment: .bottom) { + + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 300) + + VStack { + Spacer() + + VStack(alignment: .leading, spacing: 0) { + + L10n.chapters.text + .font(.title3) + .fontWeight(.bold) + .padding(.leading) + + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack { + ForEach(0 ..< viewModel.chapters.count) { chapterIndex in + VStack(alignment: .leading) { + Button { + viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) + } label: { + ImageView(src: chapterImages[chapterIndex]) + .cornerRadius(10) + .frame(width: 150, height: 100) + .overlay { + if viewModel.chapters[chapterIndex] == viewModel.currentChapter { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 4) + } + } + } + + VStack(alignment: .leading, spacing: 5) { + + Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(viewModel.chapters[chapterIndex].timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .id(viewModel.chapters[chapterIndex]) + } + } + .padding(.top) + .onAppear { + reader.scrollTo(viewModel.currentChapter) + } + } + } + } + .padding(.bottom) + } + } + } + + var body: some View { + mainBody + .edgesIgnoringSafeArea(.bottom) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didSelectChapters() + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift similarity index 96% rename from Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift rename to Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 9a969078..2925ea77 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -76,7 +76,8 @@ struct VLCPlayerOverlayView: View { } Text(viewModel.title) - .font(.system(size: 28, weight: .regular, design: .default)) + .font(.title3) + .fontWeight(.bold) .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in context[.leading] } @@ -193,6 +194,17 @@ struct VLCPlayerOverlayView: View { } } + if !viewModel.chapters.isEmpty { + Button { + viewModel.playerOverlayDelegate?.didSelectChapters() + } label: { + HStack { + Image(systemName: "list.dash") + L10n.chapters.text + } + } + } + if viewModel.shouldShowJumpButtonsInOverlayMenu { Menu { ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in @@ -247,7 +259,7 @@ struct VLCPlayerOverlayView: View { .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in context[.leading] } - .offset(y: -10) + .offset(y: -20) } } } @@ -389,6 +401,7 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { response: PlaybackInfoResponse(), audioStreams: [MediaStream(displayTitle: "English", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], selectedAudioStreamIndex: -1, selectedSubtitleStreamIndex: -1, subtitlesEnabled: true, diff --git a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift index 6ea4bc5b..f0e49c37 100644 --- a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -7,6 +7,7 @@ // import Foundation +import JellyfinAPI protocol PlayerOverlayDelegate { @@ -28,4 +29,7 @@ protocol PlayerOverlayDelegate { func didSelectPlayPreviousItem() func didSelectPlayNextItem() + + func didSelectChapters() + func didSelectChapter(_ chapter: ChapterInfo) } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift index 5af087db..dfcf1611 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift @@ -37,9 +37,14 @@ class VLCPlayerViewController: UIViewController { currentOverlayHostingController?.view.alpha ?? 0 > 0 } + private var displayingChapterOverlay: Bool { + currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 + } + private lazy var videoContentView = makeVideoContentView() private lazy var mainGestureView = makeTapGestureView() private var currentOverlayHostingController: UIHostingController? + private var currentChapterOverlayHostingController: UIHostingController? private var currentJumpBackwardOverlayView: UIImageView? private var currentJumpForwardOverlayView: UIImageView? @@ -119,6 +124,8 @@ class VLCPlayerViewController: UIViewController { @objc private func appWillResignActive() { + hideChaptersOverlay() + showOverlay() stopOverlayDismissTimer() @@ -225,6 +232,38 @@ class VLCPlayerViewController: UIViewController { self.currentOverlayHostingController = newOverlayHostingController + if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { + UIView.animate(withDuration: 0.5) { + currentChapterOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentChapterOverlayHostingController.view.isHidden = true + + currentChapterOverlayHostingController.view.removeFromSuperview() + currentChapterOverlayHostingController.removeFromParent() + } + } + + let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) + let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) + + newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newChapterOverlayHostingController.view.backgroundColor = UIColor.clear + + newChapterOverlayHostingController.view.alpha = 0 + + addChild(newChapterOverlayHostingController) + view.addSubview(newChapterOverlayHostingController.view) + newChapterOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + self.currentChapterOverlayHostingController = newChapterOverlayHostingController + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it self.navigationController?.isNavigationBarHidden = true } @@ -514,6 +553,31 @@ extension VLCPlayerViewController { } } +// MARK: Hide/Show Chapters + +extension VLCPlayerViewController { + + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } +} + // MARK: OverlayTimer extension VLCPlayerViewController { @@ -724,4 +788,27 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { startPlayback() } } + + func didSelectChapters() { + if displayingChapterOverlay { + hideChaptersOverlay() + } else { + hideOverlay() + showChaptersOverlay() + } + } + + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + + viewModel.sendProgressReport() + } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index cc09b41e..efcdd6f7 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ