Skip to content

Commit

Permalink
Support repeat modes (#993)
Browse files Browse the repository at this point in the history
Co-authored-by: Samuel Défago <[email protected]>
  • Loading branch information
waliid and defagos authored Aug 30, 2024
1 parent 173794c commit 543408a
Show file tree
Hide file tree
Showing 28 changed files with 992 additions and 262 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Quick/Nimble.git",
"state" : {
"revision" : "1c49fc1243018f81a7ea99cb5e0985b00096e9f4",
"version" : "13.3.0"
"revision" : "54b4e52183f16fe806014cbfd63718a84f8ba072",
"version" : "13.4.0"
}
},
{
Expand Down
33 changes: 33 additions & 0 deletions Demo/Sources/Model/MediaList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ enum MediaList {
)
]

static let storyUrns = [
Media(
title: "Mario vs Sonic",
subtitle: "Tataki 1",
type: .urn("urn:rts:video:13950405")
),
Media(
title: "Pourquoi Beyoncé fait de la country",
subtitle: "Tataki 2",
type: .urn("urn:rts:video:14815579")
),
Media(
title: "L'île North Sentinel",
subtitle: "Tataki 3",
type: .urn("urn:rts:video:13795051")
),
Media(
title: "Mourir pour ressembler à une idole",
subtitle: "Tataki 4",
type: .urn("urn:rts:video:14020134")
),
Media(
title: "Pourquoi les gens mangent des insectes ?",
subtitle: "Tataki 5",
type: .urn("urn:rts:video:12631996")
),
Media(
title: "Le concert de Beyoncé à Dubai",
subtitle: "Tataki 6",
type: .urn("urn:rts:video:13752646")
)
]

static let longVideoUrns = [
Media(
title: "J'ai pas l'air malade mais… (#1)",
Expand Down
26 changes: 26 additions & 0 deletions Demo/Sources/Showcase/Playlist/PlaylistView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ private struct Toolbar: View {
@ObservedObject private var model: PlaylistViewModel
@State private var isSelectionPresented = false

private var repeatModeImageName: String {
switch player.repeatMode {
case .off:
"repeat.circle"
case .one:
"repeat.1.circle.fill"
case .all:
"repeat.circle.fill"
}
}

var body: some View {
HStack {
previousButton()
Expand Down Expand Up @@ -58,6 +69,10 @@ private struct Toolbar: View {
@ViewBuilder
private func managementButtons() -> some View {
HStack(spacing: 30) {
Button(action: toggleRepeatMode) {
Image(systemName: repeatModeImageName)
}

Button(action: model.shuffle) {
Image(systemName: "shuffle")
}
Expand All @@ -82,6 +97,17 @@ private struct Toolbar: View {
.disabled(!player.canAdvanceToNext())
}

private func toggleRepeatMode() {
switch player.repeatMode {
case .off:
player.repeatMode = .all
case .one:
player.repeatMode = .off
case .all:
player.repeatMode = .one
}
}

private func add() {
isSelectionPresented.toggle()
}
Expand Down
2 changes: 1 addition & 1 deletion Demo/Sources/Showcase/Stories/StoriesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private struct TimeProgress: View {

// Behavior: h-exp, v-exp
struct StoriesView: View {
@StateObject private var model = StoriesViewModel(stories: Story.stories(from: MediaList.videoUrls))
@StateObject private var model = StoriesViewModel(stories: Story.stories(from: MediaList.storyUrns))

var body: some View {
TabView(selection: $model.currentStory) {
Expand Down
2 changes: 1 addition & 1 deletion Demo/Sources/Showcase/Stories/StoriesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ final class StoriesViewModel: ObservableObject {

private static func player(for story: Story) -> Player {
let player = Player(item: story.media.item(), configuration: .externalPlaybackDisabled)
player.actionAtItemEnd = .pause
player.repeatMode = .one
return player
}

Expand Down
25 changes: 13 additions & 12 deletions Sources/Monitoring/MetricsTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public final class MetricsTracker: PlayerItemTracker {
public func updateMetricEvents(to events: [MetricEvent]) {
switch events.last?.kind {
case .asset:
reset(with: properties)
session.start()
sendEvent(name: .start, data: startData(from: events))
startHeartbeat()
Expand All @@ -70,13 +71,7 @@ public final class MetricsTracker: PlayerItemTracker {
}

public func disable(with properties: PlayerProperties) {
defer {
reset()
}
stopHeartbeat()
if session.isStarted {
sendEvent(name: .stop, data: statusData(from: properties))
}
reset(with: properties)
}
}

Expand Down Expand Up @@ -185,10 +180,16 @@ private extension MetricsTracker {
}
}

func reset() {
stallDuration = 0
session.reset()
stopwatch.reset()
func reset(with properties: PlayerProperties?) {
defer {
stallDuration = 0
session.reset()
stopwatch.reset()
}
stopHeartbeat()
if let properties, session.isStarted {
sendEvent(name: .stop, data: statusData(from: properties))
}
}
}

Expand All @@ -200,7 +201,7 @@ private extension MetricsTracker {
}()

func sendEvent(name: EventName, data: some Encodable) {
guard let sessionId = session.id else { return}
guard let sessionId = session.id else { return }

let payload = MetricPayload(sessionId: sessionId, eventName: name, data: data)
guard let httpBody = try? Self.jsonEncoder.encode(payload) else { return }
Expand Down
71 changes: 61 additions & 10 deletions Sources/Player/Extensions/AVPlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

import AVFoundation
import AVKit

private var kIdKey: Void?

Expand All @@ -28,38 +29,75 @@ extension AVPlayerItem {
/// - currentContents: The current list of contents.
/// - previousContents: The previous list of contents.
/// - currentItem: The item currently being played by the player.
/// - repeatMode: The current repeat mode setting.
/// - length: The maximum number of items to return.
/// - Returns: The list of player items to load into the player.
static func playerItems(
for currentContents: [AssetContent],
replacing previousContents: [AssetContent],
currentItem: AVPlayerItem?,
repeatMode: RepeatMode,
length: Int
) -> [AVPlayerItem] {
guard let currentItem else { return playerItems(from: Array(currentContents.prefix(length))) }
let sources = itemSources(for: currentContents, replacing: previousContents, currentItem: currentItem)
let updatedSources = updatedItemSources(sources, repeatMode: repeatMode, firstContent: currentContents.first)
return playerItems(from: updatedSources, length: length, reload: false)
}

private static func updatedItemSources(_ sources: [ItemSource], repeatMode: RepeatMode, firstContent: AssetContent?) -> [ItemSource] {
switch repeatMode {
case .off:
return sources
case .one:
guard let firstSource = sources.first else { return sources }
var updatedSources = sources
updatedSources.insert(firstSource.copy(), at: 1)
return updatedSources
case .all:
guard let firstContent else { return sources }
var updatedSources = sources
updatedSources.append(.new(content: firstContent))
return updatedSources
}
}

private static func itemSources(
for currentContents: [AssetContent],
replacing previousContents: [AssetContent],
currentItem: AVPlayerItem?
) -> [ItemSource] {
guard let currentItem else { return newItemSources(from: Array(currentContents)) }
if let currentIndex = matchingIndex(for: currentItem, in: currentContents) {
let currentContent = currentContents[currentIndex]
if findContent(currentContent, in: previousContents) {
currentContent.update(item: currentItem)
return [currentItem] + playerItems(from: Array(currentContents.suffix(from: currentIndex + 1).prefix(length - 1)))
return [.reused(content: currentContent, item: currentItem)] + newItemSources(from: Array(currentContents.suffix(from: currentIndex + 1)))
}
else {
return playerItems(from: Array(currentContents.suffix(from: currentIndex).prefix(length)))
return newItemSources(from: Array(currentContents.suffix(from: currentIndex)))
}
}
else if let commonIndex = firstCommonIndex(in: currentContents, matching: previousContents, after: currentItem) {
return playerItems(from: Array(currentContents.suffix(from: commonIndex).prefix(length)))
return newItemSources(from: Array(currentContents.suffix(from: commonIndex)))
}
else {
return playerItems(from: Array(currentContents.prefix(length)))
return newItemSources(from: Array(currentContents))
}
}

static func playerItems(from items: [PlayerItem], length: Int, reload: Bool) -> [AVPlayerItem] {
playerItems(from: items.prefix(length).map(\.content), reload: reload)
static func playerItems(from items: [PlayerItem], after index: Int, repeatMode: RepeatMode, length: Int, reload: Bool) -> [AVPlayerItem] {
let afterContents = items.suffix(from: index).map(\.content)
let sources = updatedItemSources(newItemSources(from: afterContents), repeatMode: repeatMode, firstContent: items.first?.content)
return playerItems(from: sources, length: length, reload: reload)
}

private static func playerItems(from contents: [AssetContent], reload: Bool = false) -> [AVPlayerItem] {
contents.map { $0.playerItem(reload: reload) }
private static func playerItems(from sources: [ItemSource], length: Int, reload: Bool) -> [AVPlayerItem] {
sources
.prefix(length)
.map { $0.playerItem(reload: reload) }
}

private static func newItemSources(from contents: [AssetContent]) -> [ItemSource] {
contents.map { .new(content: $0) }
}

private static func matchingIndex(for item: AVPlayerItem, in contents: [AssetContent]) -> Int? {
Expand Down Expand Up @@ -109,4 +147,17 @@ extension AVPlayerItem {
self.id = id
return self
}

func updated(with content: AssetContent) -> AVPlayerItem {
externalMetadata = content.metadata.externalMetadata
#if os(tvOS)
interstitialTimeRanges = content.metadata.blockedTimeRanges.map { timeRange in
.init(timeRange: timeRange)
}
navigationMarkerGroups = [
AVNavigationMarkersGroup(title: "chapters", timedNavigationMarkers: content.metadata.timedNavigationMarkers)
]
#endif
return self
}
}
Loading

0 comments on commit 543408a

Please sign in to comment.