Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions LoopKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,22 @@
84AAB1E52C347EEA0054D304 /* Environment+InvestigationalDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AAB1E42C347EEA0054D304 /* Environment+InvestigationalDevice.swift */; };
84CB9CDE2C0FD94B007210DD /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB9CDD2C0FD94B007210DD /* LoopCompletionFreshnessTests.swift */; };
84DF48892C33218100844FB1 /* MuteAllAppSoundsDurationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48882C33217400844FB1 /* MuteAllAppSoundsDurationSheetView.swift */; };
84DF48E32F6C6B2100BEDB40 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DF2F6C6B2100BEDB40 /* Metadata.swift */; };
84DF48E42F6C6B2100BEDB40 /* MediaContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E02F6C6B2100BEDB40 /* MediaContent.swift */; };
84DF48E52F6C6B2100BEDB40 /* TranscriptParagraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DC2F6C6B2100BEDB40 /* TranscriptParagraph.swift */; };
84DF48E62F6C6B2100BEDB40 /* ClosedCaptionFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E22F6C6B2100BEDB40 /* ClosedCaptionFragment.swift */; };
84DF48E72F6C6B2100BEDB40 /* TranscriptExcerpt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DD2F6C6B2100BEDB40 /* TranscriptExcerpt.swift */; };
84DF48E82F6C6B2100BEDB40 /* ClosedCaptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E12F6C6B2100BEDB40 /* ClosedCaptions.swift */; };
84DF48E92F6C6B2100BEDB40 /* Transcript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DE2F6C6B2100BEDB40 /* Transcript.swift */; };
84DF48EA2F6C6B2100BEDB40 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DF2F6C6B2100BEDB40 /* Metadata.swift */; };
84DF48EB2F6C6B2100BEDB40 /* MediaContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E02F6C6B2100BEDB40 /* MediaContent.swift */; };
84DF48EC2F6C6B2100BEDB40 /* TranscriptParagraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DC2F6C6B2100BEDB40 /* TranscriptParagraph.swift */; };
84DF48ED2F6C6B2100BEDB40 /* ClosedCaptionFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E22F6C6B2100BEDB40 /* ClosedCaptionFragment.swift */; };
84DF48EE2F6C6B2100BEDB40 /* TranscriptExcerpt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DD2F6C6B2100BEDB40 /* TranscriptExcerpt.swift */; };
84DF48EF2F6C6B2100BEDB40 /* ClosedCaptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48E12F6C6B2100BEDB40 /* ClosedCaptions.swift */; };
84DF48F02F6C6B2100BEDB40 /* Transcript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48DE2F6C6B2100BEDB40 /* Transcript.swift */; };
84DF48F22F6C6B9000BEDB40 /* TimeInterval+Timecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48F12F6C6B9000BEDB40 /* TimeInterval+Timecode.swift */; };
84DF48F32F6C6B9000BEDB40 /* TimeInterval+Timecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48F12F6C6B9000BEDB40 /* TimeInterval+Timecode.swift */; };
84E8BBBE2CC9976E0078E6CF /* BulletedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBBD2CC9976E0078E6CF /* BulletedListView.swift */; };
84EE97812D71293E00D5E941 /* GlucoseHistoryLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EE97802D71293E00D5E941 /* GlucoseHistoryLayer.swift */; };
84EE97B12D7A42DB00D5E941 /* ChartPointsScatterBorderedCirclesLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EE97B02D7A42DB00D5E941 /* ChartPointsScatterBorderedCirclesLayer.swift */; };
Expand Down Expand Up @@ -1344,6 +1360,14 @@
84AAB1E42C347EEA0054D304 /* Environment+InvestigationalDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+InvestigationalDevice.swift"; sourceTree = "<group>"; };
84CB9CDD2C0FD94B007210DD /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = "<group>"; };
84DF48882C33217400844FB1 /* MuteAllAppSoundsDurationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAllAppSoundsDurationSheetView.swift; sourceTree = "<group>"; };
84DF48DC2F6C6B2100BEDB40 /* TranscriptParagraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptParagraph.swift; sourceTree = "<group>"; };
84DF48DD2F6C6B2100BEDB40 /* TranscriptExcerpt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptExcerpt.swift; sourceTree = "<group>"; };
84DF48DE2F6C6B2100BEDB40 /* Transcript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transcript.swift; sourceTree = "<group>"; };
84DF48DF2F6C6B2100BEDB40 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
84DF48E02F6C6B2100BEDB40 /* MediaContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContent.swift; sourceTree = "<group>"; };
84DF48E12F6C6B2100BEDB40 /* ClosedCaptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedCaptions.swift; sourceTree = "<group>"; };
84DF48E22F6C6B2100BEDB40 /* ClosedCaptionFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedCaptionFragment.swift; sourceTree = "<group>"; };
84DF48F12F6C6B9000BEDB40 /* TimeInterval+Timecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Timecode.swift"; sourceTree = "<group>"; };
84E8BBBD2CC9976E0078E6CF /* BulletedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletedListView.swift; sourceTree = "<group>"; };
84EE97802D71293E00D5E941 /* GlucoseHistoryLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseHistoryLayer.swift; sourceTree = "<group>"; };
84EE97B02D7A42DB00D5E941 /* ChartPointsScatterBorderedCirclesLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPointsScatterBorderedCirclesLayer.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2330,6 +2354,7 @@
437AFF22203BE382008C4892 /* Extensions */ = {
isa = PBXGroup;
children = (
84DF48F12F6C6B9000BEDB40 /* TimeInterval+Timecode.swift */,
C187338329B9486200519CDF /* ClosedRange.swift */,
C187337F29B9486100519CDF /* Collection.swift */,
C187338029B9486100519CDF /* Comparable.swift */,
Expand Down Expand Up @@ -2486,6 +2511,7 @@
43D8FDCD1C728FDF0073BE78 /* LoopKit */ = {
isa = PBXGroup;
children = (
84DF48DB2F6C6B1200BEDB40 /* Media */,
C15F9A572EB65D3B0082BDF4 /* TestingDate.swift */,
1DA649AA2445174400F61E75 /* Alert.swift */,
A96E6C3627B35BC600F81A5B /* AnyCodableEquatable.swift */,
Expand Down Expand Up @@ -2805,6 +2831,20 @@
path = Presets;
sourceTree = "<group>";
};
84DF48DB2F6C6B1200BEDB40 /* Media */ = {
isa = PBXGroup;
children = (
84DF48DC2F6C6B2100BEDB40 /* TranscriptParagraph.swift */,
84DF48DD2F6C6B2100BEDB40 /* TranscriptExcerpt.swift */,
84DF48DE2F6C6B2100BEDB40 /* Transcript.swift */,
84DF48DF2F6C6B2100BEDB40 /* Metadata.swift */,
84DF48E02F6C6B2100BEDB40 /* MediaContent.swift */,
84DF48E12F6C6B2100BEDB40 /* ClosedCaptions.swift */,
84DF48E22F6C6B2100BEDB40 /* ClosedCaptionFragment.swift */,
);
path = Media;
sourceTree = "<group>";
};
892A5D35222F03CB008961AB /* LoopTestingKit */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -4164,6 +4204,13 @@
A9498D8223386C3300DAA9B9 /* Service.swift in Sources */,
C187338F29B9486200519CDF /* NumberFormatter.swift in Sources */,
4322B785202FA2AF0002837D /* ReservoirValue.swift in Sources */,
84DF48E32F6C6B2100BEDB40 /* Metadata.swift in Sources */,
84DF48E42F6C6B2100BEDB40 /* MediaContent.swift in Sources */,
84DF48E52F6C6B2100BEDB40 /* TranscriptParagraph.swift in Sources */,
84DF48E62F6C6B2100BEDB40 /* ClosedCaptionFragment.swift in Sources */,
84DF48E72F6C6B2100BEDB40 /* TranscriptExcerpt.swift in Sources */,
84DF48E82F6C6B2100BEDB40 /* ClosedCaptions.swift in Sources */,
84DF48E92F6C6B2100BEDB40 /* Transcript.swift in Sources */,
B4A2ABEA2AA9F210007E3EC1 /* BolusActivationType.swift in Sources */,
891A3FD9224BEB4600378B27 /* EGPSchedule.swift in Sources */,
89AE2229228BC54C00BDFD85 /* TemporaryScheduleOverrideHistory.swift in Sources */,
Expand All @@ -4177,6 +4224,7 @@
4322B783202FA2AF0002837D /* Reservoir.swift in Sources */,
1DA649AB2445174400F61E75 /* Alert.swift in Sources */,
4322B782202FA2AF0002837D /* PumpEventType.swift in Sources */,
84DF48F32F6C6B9000BEDB40 /* TimeInterval+Timecode.swift in Sources */,
89AE2228228BC54C00BDFD85 /* TemporaryPresetSettings.swift in Sources */,
43D8FDFA1C7290350073BE78 /* GlucoseRangeSchedule.swift in Sources */,
4322B77E202FA2AF0002837D /* PersistedPumpEvent.swift in Sources */,
Expand Down Expand Up @@ -4497,6 +4545,13 @@
C1092410286A4ADB00FAD2B8 /* AutomaticDosingStrategy.swift in Sources */,
C17F39CB23CD2D2F00FA1113 /* DeviceLogEntryType.swift in Sources */,
A9E675AF22713F4700E25293 /* PumpEventType.swift in Sources */,
84DF48EA2F6C6B2100BEDB40 /* Metadata.swift in Sources */,
84DF48EB2F6C6B2100BEDB40 /* MediaContent.swift in Sources */,
84DF48EC2F6C6B2100BEDB40 /* TranscriptParagraph.swift in Sources */,
84DF48ED2F6C6B2100BEDB40 /* ClosedCaptionFragment.swift in Sources */,
84DF48EE2F6C6B2100BEDB40 /* TranscriptExcerpt.swift in Sources */,
84DF48EF2F6C6B2100BEDB40 /* ClosedCaptions.swift in Sources */,
84DF48F02F6C6B2100BEDB40 /* Transcript.swift in Sources */,
C1614F062AAFC36200F636E5 /* CgmEvent.swift in Sources */,
C11A17482CB713C10019C517 /* Model.xcdatamodeld in Sources */,
C1A174ED23DEAD6A0034DF11 /* DeviceLogEntry+CoreDataProperties.swift in Sources */,
Expand Down Expand Up @@ -4547,6 +4602,7 @@
A9E675D422713F4700E25293 /* CachedGlucoseObject+CoreDataClass.swift in Sources */,
A9498D7F23386C3300DAA9B9 /* GlucoseThreshold.swift in Sources */,
A9E675D522713F4700E25293 /* WalshInsulinModel.swift in Sources */,
84DF48F22F6C6B9000BEDB40 /* TimeInterval+Timecode.swift in Sources */,
A9A53E2D2714E5BC0050C0B1 /* CodableDevice.swift in Sources */,
840A2DD42E399BD400D4E245 /* Modelv5ToModelv6.xcmappingmodel in Sources */,
B40C43912707408400F5D86C /* DeliveryLimits.swift in Sources */,
Expand Down
63 changes: 63 additions & 0 deletions LoopKit/Extensions/TimeInterval+Timecode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// TimeInterval+Timecode.swift
// Loop
//
// Created by Cameron Ingham on 2/27/25.
//

import Foundation

public extension TimeInterval {
enum TimecodeStyle {
case caption
case transcript

public var separator: String {
switch self {
case .caption:
return ":,"
case .transcript:
return ":"
}
}
}

init?(timecode: String, style: TimecodeStyle) {
self.init(timecode: timecode, separator: style.separator)
}

init?(timecode: String, separator: String) {
let components = timecode.components(separatedBy: CharacterSet(charactersIn: separator))

guard components.count >= 3,
let hours = Int(components[0]),
let minutes = Int(components[1]),
let seconds = Int(components[2]) else {
return nil
}

let totalSeconds = Double(hours * 3600 + minutes * 60 + seconds)

var totalMilliseconds: Double = 0
if components.count > 3, let milliseconds = Int(components[3]) {
totalMilliseconds = Double(milliseconds) / 1000.0
}

self = totalSeconds + totalMilliseconds
}

func timecode(for style: TimecodeStyle) -> String {
let totalSeconds = Int(self)
let milliseconds = Int((self.truncatingRemainder(dividingBy: 1)) * 1000)
let seconds = totalSeconds % 60
let minutes = (totalSeconds % 3600) / 60
let hours = totalSeconds / 3600

switch style {
case .caption:
return String(format: "%02d:%02d:%02d,%03d", hours, minutes, seconds, milliseconds)
case .transcript:
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
}
33 changes: 33 additions & 0 deletions LoopKit/Media/ClosedCaptionFragment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// ClosedCaptionFragment.swift
// Loop
//
// Created by Cameron Ingham on 2/27/25.
//

import Foundation

public struct ClosedCaptionFragment: Equatable, Hashable, RawRepresentable {

public let sequenceNumber: Int
public let startTime: TimeInterval
public let endTime: TimeInterval
public let text: String

public var rawValue: String {
"""
\(sequenceNumber)
\(String(describing: startTime.timecode)) --> \(String(describing: endTime.timecode))
\(text)
"""
}

public init?(rawValue: String) {
let rawFragments = rawValue.split(separator: "\n")
self.sequenceNumber = Int(rawFragments[0])!
let timecodes = rawFragments[1].split(separator: " --> ")
self.startTime = TimeInterval(timecode: String(timecodes[0]), style: .caption)!
self.endTime = TimeInterval(timecode: String(timecodes[1]), style: .caption)!
self.text = String(rawFragments[2])
}
}
42 changes: 42 additions & 0 deletions LoopKit/Media/ClosedCaptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// ClosedCaptions.swift
// Loop
//
// Created by Cameron Ingham on 2/27/25.
//

import Foundation

public struct ClosedCaptions: Equatable, Hashable, RawRepresentable {

public var fragments: [ClosedCaptionFragment]

public var rawValue: String {
fragments.map(\.rawValue).joined(separator: "\n\n")
}

public init(fragments: [ClosedCaptionFragment]) {
self.fragments = fragments
}

public init(url: URL) {
guard let data = try? Data(contentsOf: url) else {
assertionFailure("Could not generate data from file at URL: \(url.absoluteString)")
self.init(fragments: [])
return
}

let rawValue = String(data: data, encoding: .utf8)!
self.init(rawValue: rawValue)
}

public init(rawValue: String) {
self.fragments = rawValue.split(separator: "\n\n").compactMap {
ClosedCaptionFragment(rawValue: String($0))
}
}

public func currentFragment(at timecode: TimeInterval) -> ClosedCaptionFragment? {
fragments.first(where: { timecode >= $0.startTime && timecode < $0.endTime })
}
}
49 changes: 49 additions & 0 deletions LoopKit/Media/MediaContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// MediaContent.swift
// Loop
//
// Created by Cameron Ingham on 2/27/25.
//

import AVFoundation
import Foundation

public struct MediaContent: Equatable, Hashable, Identifiable {

public struct StaticImage: Hashable {
public let name: String
public let bundle: Bundle
}

public let fileName: String
public let metadata: Metadata
public let staticImage: StaticImage
public let animation: URL
public let audio: URL
public let transcript: Transcript?
public let closedCaptions: ClosedCaptions

public let asset: AVAsset

public init(_ name: String, bundle: Bundle?) {
self.fileName = name
self.metadata = Metadata(url: (bundle ?? Bundle.main).url(forResource: name, withExtension: "json")!)!
self.staticImage = StaticImage(name: name, bundle: bundle ?? Bundle.main)
self.animation = (bundle ?? Bundle.main).url(forResource: name, withExtension: "mp4")!
self.audio = (bundle ?? Bundle.main).url(forResource: name, withExtension: "mp3")!
self.transcript = Transcript(url: (bundle ?? Bundle.main).url(forResource: name, withExtension: "txt")!)
self.closedCaptions = ClosedCaptions(url: (bundle ?? Bundle.main).url(forResource: name, withExtension: "srt")!)

self.asset = AVAsset(url: audio)
}

public var duration: TimeInterval {
get async throws {
try await asset.load(.duration).seconds
}
}

public var id: Int {
hashValue
}
}
24 changes: 24 additions & 0 deletions LoopKit/Media/Metadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Metadata.swift
// Loop
//
// Created by Cameron Ingham on 8/11/25.
//

import Foundation

public struct Metadata: Equatable, Hashable, Decodable {
public let title: String
public let author: String

public init?(url: URL) {
do {
let metadata = try JSONDecoder().decode(Self.self, from: Data(contentsOf: url))
self.title = metadata.title
self.author = metadata.author
} catch {
print(error.localizedDescription)
return nil
}
}
}
42 changes: 42 additions & 0 deletions LoopKit/Media/Transcript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// Transcript.swift
// Loop
//
// Created by Cameron Ingham on 7/16/25.
//

import Foundation

public struct Transcript: Equatable, Hashable, RawRepresentable {

public let paragraphs: [TranscriptParagraph]

public var rawValue: String {
paragraphs.map(\.rawValue).joined(separator: "\n\n")
}

public init(paragraphs: [TranscriptParagraph]) {
self.paragraphs = paragraphs
}

public init(rawValue: String) {
self.paragraphs = rawValue.split(separator: "\n\n").compactMap {
TranscriptParagraph(rawValue: String($0))
}
}

public init(url: URL) {
guard let data = try? Data(contentsOf: url) else {
assertionFailure("Could not generate data from file at URL: \(url.absoluteString)")
self.init(paragraphs: [])
return
}

let rawValue = String(data: data, encoding: .utf8)!
self.init(rawValue: rawValue)
}

public func currentExcerpt(at timecode: TimeInterval) -> TranscriptExcerpt {
paragraphs.flatMap(\.excerpts).last(where: { $0.startTime <= timecode }) ?? TranscriptExcerpt(startTime: 0, text: "")
}
}
Loading