Merge pull request #306 from LePips/chapter-support

This commit is contained in:
aiden 3 2022-01-16 22:24:42 -05:00 committed by GitHub
commit a799ad4d8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 550 additions and 73 deletions

View File

@ -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,

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -50,6 +50,8 @@ internal enum L10n {
internal static var changeServer: String { return L10n.tr("Localizable", "changeServer") }
/// Channels
internal static var channels: String { return L10n.tr("Localizable", "channels") }
/// Chapters
internal static var chapters: String { return L10n.tr("Localizable", "chapters") }
/// Cinematic Views
internal static var cinematicViews: String { return L10n.tr("Localizable", "cinematicViews") }
/// Closed Captions

View File

@ -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<VideoPlayerViewModel, Never>()
@ -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

View File

@ -16,6 +16,7 @@ struct SmallMediaStreamSelectionView: View {
case subtitles
case audio
case playbackSpeed
case chapters
}
enum MediaSection: Hashable {
@ -25,9 +26,12 @@ struct SmallMediaStreamSelectionView: View {
@ObservedObject
var viewModel: VideoPlayerViewModel
private let chapterImages: [URL]
@State
private var updateFocusedLayer: Layer = .subtitles
@State
private var lastFocusedLayer: Layer = .subtitles
@FocusState
private var subtitlesFocused: Bool
@ -36,6 +40,8 @@ struct SmallMediaStreamSelectionView: View {
@FocusState
private var playbackSpeedFocused: Bool
@FocusState
private var chaptersFocused: Bool
@FocusState
private var focusedSection: MediaSection?
@FocusState
private var focusedLayer: Layer? {
@ -48,8 +54,10 @@ struct SmallMediaStreamSelectionView: View {
}
}
@State
private var lastFocusedLayer: Layer = .subtitles
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500)
}
var body: some View {
ZStack(alignment: .bottom) {
@ -161,6 +169,40 @@ struct SmallMediaStreamSelectionView: View {
}
}
// MARK: Chapters Header
if !viewModel.chapters.isEmpty {
Button {
updateFocusedLayer = .chapters
focusedLayer = .chapters
} label: {
if updateFocusedLayer == .chapters {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .chapters)
.focused($chaptersFocused)
.onChange(of: chaptersFocused) { isFocused in
if isFocused {
focusedLayer = .chapters
}
}
}
Spacer()
}
.padding()
@ -181,80 +223,144 @@ struct SmallMediaStreamSelectionView: View {
if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles {
// MARK: Subtitles
ScrollView(.horizontal) {
HStack {
if viewModel.subtitleStreams.isEmpty {
Button {} label: {
L10n.none.text
}
} else {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
subtitleMenuView
} else if updateFocusedLayer == .audio && lastFocusedLayer == .audio {
// MARK: Audio
ScrollView(.horizontal) {
HStack {
if viewModel.audioStreams.isEmpty {
Button {} label: {
Text("None")
}
} else {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
audioMenuView
} else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed {
// MARK: Rates
// MARK: Playback Speed
ScrollView(.horizontal) {
HStack {
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
Button {
viewModel.playbackSpeed = playbackSpeed
} label: {
if playbackSpeed == viewModel.playbackSpeed {
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
} else {
Text(playbackSpeed.displayTitle)
}
}
playbackSpeedMenuView
} else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters {
// MARK: Chapters
chaptersMenuView
}
}
}
}
@ViewBuilder
private var subtitleMenuView: some View {
ScrollView(.horizontal) {
HStack {
if viewModel.subtitleStreams.isEmpty {
Button {} label: {
L10n.none.text
}
} else {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? L10n.noTitle)
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var audioMenuView: some View {
ScrollView(.horizontal) {
HStack {
if viewModel.audioStreams.isEmpty {
Button {} label: {
Text("None")
}
} else {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var playbackSpeedMenuView: some View {
ScrollView(.horizontal) {
HStack {
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
Button {
viewModel.playbackSpeed = playbackSpeed
} label: {
if playbackSpeed == viewModel.playbackSpeed {
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
} else {
Text(playbackSpeed.displayTitle)
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var chaptersMenuView: some View {
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: 350, height: 210)
}
.buttonStyle(CardButtonStyle())
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)
}
}
}
}
}

View File

@ -147,6 +147,7 @@ struct tvOSVLCOverlay_Previews: PreviewProvider {
response: PlaybackInfoResponse(),
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
chapters: [],
selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1,
subtitlesEnabled: true,

View File

@ -7,6 +7,7 @@
//
import Foundation
import JellyfinAPI
protocol PlayerOverlayDelegate {
@ -27,4 +28,6 @@ protocol PlayerOverlayDelegate {
func didSelectPlayPreviousItem()
func didSelectPlayNextItem()
func didSelectChapter(_ chapter: ChapterInfo)
}

View File

@ -881,4 +881,18 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
startPlayback()
}
}
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()
}
}

View File

@ -239,6 +239,11 @@
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 */; };
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* 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 */; };
@ -651,6 +656,8 @@
C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = "<group>"; };
C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = "<group>"; };
E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = "<group>"; };
E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = "<group>"; };
E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICinematicBackgroundView.swift; sourceTree = "<group>"; };
E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicBackgroundView.swift; sourceTree = "<group>"; };
@ -781,6 +788,7 @@
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */,
E11D83AF278FA998006E9776 /* NukeUI in Frameworks */,
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */,
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */,
6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */,
535870912669D7A800D05A09 /* Introspect in Frameworks */,
536D3D84267BEA550004248C /* ParallaxView in Frameworks */,
@ -802,6 +810,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 */,
@ -849,7 +858,7 @@
children = (
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
E178859C2780F5300094FBCF /* tvOSSLider */,
E17885A7278130690094FBCF /* tvOSOverlay */,
E17885A7278130690094FBCF /* Overlays */,
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
E1384943278036C70024FB48 /* VLCPlayerViewController.swift */,
);
@ -1348,6 +1357,15 @@
path = Pods;
sourceTree = "<group>";
};
E1002B692793E12E00E47059 /* Overlays */ = {
isa = PBXGroup;
children = (
E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */,
E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */,
);
path = Overlays;
sourceTree = "<group>";
};
E103A6A1278A7EB500820EC7 /* HomeCinematicView */ = {
isa = PBXGroup;
children = (
@ -1505,14 +1523,14 @@
path = tvOSSLider;
sourceTree = "<group>";
};
E17885A7278130690094FBCF /* tvOSOverlay */ = {
E17885A7278130690094FBCF /* Overlays */ = {
isa = PBXGroup;
children = (
E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */,
E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */,
E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */,
);
path = tvOSOverlay;
path = Overlays;
sourceTree = "<group>";
};
E18845FA26DEACBE00B0C5B7 /* Portrait */ = {
@ -1548,7 +1566,7 @@
isa = PBXGroup;
children = (
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */,
E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */,
E1002B692793E12E00E47059 /* Overlays */,
E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */,
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */,
);
@ -1572,6 +1590,7 @@
E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */,
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */,
5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */,
E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */,
E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */,
E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */,
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */,
@ -1720,6 +1739,7 @@
E178857C278037FD0094FBCF /* JellyfinAPI */,
E1AE8E7D2789136D00FBDDAA /* Nuke */,
E11D83AE278FA998006E9776 /* NukeUI */,
E1002B6A2793E36600E47059 /* Algorithms */,
);
productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
@ -1759,6 +1779,7 @@
E10EAA4C277BB716000269ED /* Sliders */,
E1AE8E7B2789135A00FBDDAA /* Nuke */,
E1361DA6278FA7A300BEC523 /* NukeUI */,
E1002B672793CFBA00E47059 /* Algorithms */,
);
productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -1852,14 +1873,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 */
@ -2143,6 +2165,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 */,
@ -2346,6 +2369,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 */,
@ -2365,6 +2389,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 */,
@ -3003,6 +3028,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";
@ -3125,6 +3158,16 @@
package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */;
productName = Stinsen;
};
E1002B672793CFBA00E47059 /* Algorithms */ = {
isa = XCSwiftPackageProductDependency;
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
productName = Algorithms;
};
E1002B6A2793E36600E47059 /* Algorithms */ = {
isa = XCSwiftPackageProductDependency;
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
productName = Algorithms;
};
E10EAA44277BB646000269ED /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;

View File

@ -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",

View File

@ -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()
}
}
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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<VLCPlayerOverlayView>?
private var currentChapterOverlayHostingController: UIHostingController<VLCPlayerChapterOverlayView>?
private var currentJumpBackwardOverlayView: UIImageView?
private var currentJumpForwardOverlayView: UIImageView?
@ -120,6 +125,8 @@ class VLCPlayerViewController: UIViewController {
@objc
private func appWillResignActive() {
hideChaptersOverlay()
showOverlay()
stopOverlayDismissTimer()
@ -226,6 +233,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
}
@ -515,6 +554,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 {
@ -725,4 +789,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()
}
}