mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2025-02-25 01:31:32 +00:00
Merge pull request #306 from LePips/chapter-support
This commit is contained in:
commit
a799ad4d8c
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
protocol PlayerOverlayDelegate {
|
||||
|
||||
@ -27,4 +28,6 @@ protocol PlayerOverlayDelegate {
|
||||
|
||||
func didSelectPlayPreviousItem()
|
||||
func didSelectPlayNextItem()
|
||||
|
||||
func didSelectChapter(_ chapter: ChapterInfo)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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" */;
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user