diff options
| -rw-r--r-- | player-android/AGENTS.md | 7 | ||||
| -rw-r--r-- | player-android/README.md | 16 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 62 | ||||
| -rw-r--r-- | player-android/lib/main.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/models/json_helpers.dart | 4 | ||||
| -rw-r--r-- | player-android/lib/models/media.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/models/media_set.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/models/models.dart | 9 | ||||
| -rw-r--r-- | player-android/lib/models/note.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/models/playback_hint.dart | 14 | ||||
| -rw-r--r-- | player-android/lib/models/podcast_episode.dart | 17 | ||||
| -rw-r--r-- | player-android/lib/models/podcast_feed.dart | 14 | ||||
| -rw-r--r-- | player-android/lib/models/share.dart | 17 | ||||
| -rw-r--r-- | player-android/lib/models/tag.dart | 11 | ||||
| -rw-r--r-- | player-android/lib/models/user.dart | 14 | ||||
| -rw-r--r-- | player-android/pubspec.yaml | 18 |
16 files changed, 267 insertions, 0 deletions
diff --git a/player-android/AGENTS.md b/player-android/AGENTS.md new file mode 100644 index 0000000..ad2f1ef --- /dev/null +++ b/player-android/AGENTS.md @@ -0,0 +1,7 @@ +# Player Android Agent Notes + +This directory is the Flutter Android client for the Player server. + +- Keep the scaffold simple until the app has real UI requirements. +- Do not add a state-management framework without a concrete need. +- Keep API client methods aligned with `../player-server/docs/api.md` and server routes. diff --git a/player-android/README.md b/player-android/README.md new file mode 100644 index 0000000..e365f9b --- /dev/null +++ b/player-android/README.md @@ -0,0 +1,16 @@ +# Player Android + +Flutter Android client scaffold for the Player server. + +## Quickstart + +When Flutter is installed, finish or refresh the generated Android project files: + +```sh +cd player-android +flutter create --org zone.foo --project-name player_android --platforms=android --description 'Player Android client' . +flutter analyze +flutter build apk --debug +``` + +The REST API contract lives in [../player-server/docs/api.md](../player-server/docs/api.md). diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart new file mode 100644 index 0000000..3031c73 --- /dev/null +++ b/player-android/lib/api/player_api_client.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; + +import '../models/models.dart'; + +class PlayerApiClient { + final Uri baseUrl; + final String bearerToken; + + const PlayerApiClient({required this.baseUrl, required this.bearerToken}); + + Future<User> bootstrap({required String username, required String password}) => throw UnimplementedError(); + Future<User> login({required String username, required String password}) => throw UnimplementedError(); + Future<void> logout() => throw UnimplementedError(); + Future<void> healthz() => throw UnimplementedError(); + Future<void> readyz() => throw UnimplementedError(); + Future<String> getSharedMediaPage(String token) => throw UnimplementedError(); + Future<Uint8List> streamSharedMedia(String token, {String? range}) => throw UnimplementedError(); + Future<Uint8List> getSharedThumbnail(String token) => throw UnimplementedError(); + Future<Uint8List> downloadSharedMedia(String token) => throw UnimplementedError(); + Future<Map<String, dynamic>> getConfig() => throw UnimplementedError(); + Future<List<MediaSet>> listSets() => throw UnimplementedError(); + Future<Map<String, dynamic>> browseSet(int setId, {String? parent}) => throw UnimplementedError(); + Future<Uint8List> getSetCover(int setId, {String? folder}) => throw UnimplementedError(); + Future<void> updateSetCover(int setId, {String? folder}) => throw UnimplementedError(); + Future<Media> uploadToSet(int setId, {required String fileName, required List<int> bytes}) => throw UnimplementedError(); + Future<List<Media>> listMedia({String? search, int? setId, List<int>? setIds, String? type, bool? favorites, List<String>? tags, double? minDuration, double? maxDuration, int? fileSizeMin, int? fileSizeMax, String? sort, int? limit, int? offset, String? folder, String? parent}) => throw UnimplementedError(); + Future<Media> getMedia(int mediaId) => throw UnimplementedError(); + Future<Uint8List> streamMedia(int mediaId, {String? range}) => throw UnimplementedError(); + Future<Uint8List> downloadMedia(int mediaId) => throw UnimplementedError(); + Future<Uint8List> getThumbnail(int mediaId) => throw UnimplementedError(); + Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError(); + Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError(); + Future<List<Tag>> listTags() => throw UnimplementedError(); + Future<void> addTag(int mediaId, String tag) => throw UnimplementedError(); + Future<void> removeTag(int mediaId, String tag) => throw UnimplementedError(); + Future<Share> createShare(int mediaId, {DateTime? expiresAt, int? maxUses}) => throw UnimplementedError(); + Future<List<Share>> listSharesForMedia(int mediaId) => throw UnimplementedError(); + Future<List<Share>> listMyShares() => throw UnimplementedError(); + Future<void> revokeShare(String token) => throw UnimplementedError(); + Future<Note?> getNote(int mediaId) => throw UnimplementedError(); + Future<Note> upsertNote(int mediaId, String content) => throw UnimplementedError(); + Future<void> deleteNote(int mediaId) => throw UnimplementedError(); + Future<void> updateProgress({required int mediaId, required double positionSeconds}) => throw UnimplementedError(); + Future<void> updateProgressStatus({required int mediaId, required String status}) => throw UnimplementedError(); + Future<List<Media>> listInProgress() => throw UnimplementedError(); + Future<void> deleteMedia(int mediaId) => throw UnimplementedError(); + Future<void> restoreMedia(int mediaId) => throw UnimplementedError(); + Future<List<PodcastFeed>> listPodcasts() => throw UnimplementedError(); + Future<List<PodcastEpisode>> listEpisodes(int podcastSetId, {int? limit, int? offset}) => throw UnimplementedError(); + Future<Media> downloadEpisode(int episodeId) => throw UnimplementedError(); + Future<void> toggleEpisodeComplete(int episodeId) => throw UnimplementedError(); + Future<PodcastFeed> subscribePodcast({required String feedUrl, String? setName}) => throw UnimplementedError(); + Future<List<User>> listUsers() => throw UnimplementedError(); + Future<User> createUser({required String username, required String password, required bool isAdmin}) => throw UnimplementedError(); + Future<void> deleteUser(int userId) => throw UnimplementedError(); + Future<List<Map<String, dynamic>>> listPermissions() => throw UnimplementedError(); + Future<void> grantPermission({required int setId, required int userId, required String role}) => throw UnimplementedError(); + Future<void> revokePermission({required int setId, required int userId}) => throw UnimplementedError(); + Future<void> triggerRescan() => throw UnimplementedError(); + Future<Map<String, dynamic>> getScanProgress() => throw UnimplementedError(); + Future<List<Media>> listTrash() => throw UnimplementedError(); +} diff --git a/player-android/lib/main.dart b/player-android/lib/main.dart new file mode 100644 index 0000000..1feb6e0 --- /dev/null +++ b/player-android/lib/main.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +void main() => runApp(const PlayerAndroidApp()); + +class PlayerAndroidApp extends StatelessWidget { + const PlayerAndroidApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center(child: Text('Player Android')), + ), + ); + } +} diff --git a/player-android/lib/models/json_helpers.dart b/player-android/lib/models/json_helpers.dart new file mode 100644 index 0000000..c966476 --- /dev/null +++ b/player-android/lib/models/json_helpers.dart @@ -0,0 +1,4 @@ +DateTime? dateTimeFromJson(Object? value) => + value is String && value.isNotEmpty ? DateTime.parse(value) : null; + +String? dateTimeToJson(DateTime? value) => value?.toIso8601String(); diff --git a/player-android/lib/models/media.dart b/player-android/lib/models/media.dart new file mode 100644 index 0000000..7330bca --- /dev/null +++ b/player-android/lib/models/media.dart @@ -0,0 +1,16 @@ +import 'json_helpers.dart'; + +class Media { + final int id, setId, bitrate, fileSizeBytes, width, height, playCount; + final String relPath, fileName, absPath, type, codec, resolution, thumbnailPath; + final double duration; + final bool favorite; + final List<String> tags; + final DateTime? deletedAt, createdAt; + + const Media({required this.id, required this.setId, required this.relPath, required this.fileName, required this.absPath, required this.type, required this.duration, required this.codec, required this.resolution, required this.bitrate, required this.fileSizeBytes, required this.width, required this.height, required this.thumbnailPath, required this.playCount, this.favorite = false, this.tags = const [], this.deletedAt, this.createdAt}); + + factory Media.fromJson(Map<String, dynamic> json) => Media(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, relPath: json['rel_path'] as String? ?? '', fileName: json['file_name'] as String? ?? '', absPath: json['abs_path'] as String? ?? '', type: json['type'] as String? ?? '', duration: (json['duration'] as num?)?.toDouble() ?? 0, codec: json['codec'] as String? ?? '', resolution: json['resolution'] as String? ?? '', bitrate: json['bitrate'] as int? ?? 0, fileSizeBytes: json['file_size_bytes'] as int? ?? 0, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, thumbnailPath: json['thumbnail_path'] as String? ?? '', playCount: json['play_count'] as int? ?? 0, favorite: json['favorite'] as bool? ?? false, tags: (json['tags'] as List<dynamic>? ?? const []).cast<String>(), deletedAt: dateTimeFromJson(json['deleted_at']), createdAt: dateTimeFromJson(json['created_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'set_id': setId, 'rel_path': relPath, 'file_name': fileName, 'abs_path': absPath, 'type': type, 'duration': duration, 'codec': codec, 'resolution': resolution, 'bitrate': bitrate, 'file_size_bytes': fileSizeBytes, 'width': width, 'height': height, 'thumbnail_path': thumbnailPath, 'play_count': playCount, 'favorite': favorite, 'tags': tags, 'deleted_at': dateTimeToJson(deletedAt), 'created_at': dateTimeToJson(createdAt)}; +} diff --git a/player-android/lib/models/media_set.dart b/player-android/lib/models/media_set.dart new file mode 100644 index 0000000..1e809cb --- /dev/null +++ b/player-android/lib/models/media_set.dart @@ -0,0 +1,16 @@ +import 'json_helpers.dart'; + +class MediaSet { + final int id; + final String name; + final String rootPath; + final String coverThumbnailPath; + final bool isPodcast; + final DateTime? createdAt; + + const MediaSet({required this.id, required this.name, required this.rootPath, required this.coverThumbnailPath, required this.isPodcast, this.createdAt}); + + factory MediaSet.fromJson(Map<String, dynamic> json) => MediaSet(id: json['id'] as int? ?? 0, name: json['name'] as String? ?? '', rootPath: json['root_path'] as String? ?? '', coverThumbnailPath: json['cover_thumbnail_path'] as String? ?? '', isPodcast: json['is_podcast'] as bool? ?? false, createdAt: dateTimeFromJson(json['created_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'name': name, 'root_path': rootPath, 'cover_thumbnail_path': coverThumbnailPath, 'is_podcast': isPodcast, 'created_at': dateTimeToJson(createdAt)}; +} diff --git a/player-android/lib/models/models.dart b/player-android/lib/models/models.dart new file mode 100644 index 0000000..2b11901 --- /dev/null +++ b/player-android/lib/models/models.dart @@ -0,0 +1,9 @@ +export 'media.dart'; +export 'media_set.dart'; +export 'note.dart'; +export 'playback_hint.dart'; +export 'podcast_episode.dart'; +export 'podcast_feed.dart'; +export 'share.dart'; +export 'tag.dart'; +export 'user.dart'; diff --git a/player-android/lib/models/note.dart b/player-android/lib/models/note.dart new file mode 100644 index 0000000..381008d --- /dev/null +++ b/player-android/lib/models/note.dart @@ -0,0 +1,16 @@ +import 'json_helpers.dart'; + +class Note { + final int id; + final int mediaId; + final int userId; + final String content; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Note({required this.id, required this.mediaId, required this.userId, required this.content, this.createdAt, this.updatedAt}); + + factory Note.fromJson(Map<String, dynamic> json) => Note(id: json['id'] as int? ?? 0, mediaId: json['media_id'] as int? ?? 0, userId: json['user_id'] as int? ?? 0, content: json['content'] as String? ?? '', createdAt: dateTimeFromJson(json['created_at']), updatedAt: dateTimeFromJson(json['updated_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'media_id': mediaId, 'user_id': userId, 'content': content, 'created_at': dateTimeToJson(createdAt), 'updated_at': dateTimeToJson(updatedAt)}; +} diff --git a/player-android/lib/models/playback_hint.dart b/player-android/lib/models/playback_hint.dart new file mode 100644 index 0000000..076fd4d --- /dev/null +++ b/player-android/lib/models/playback_hint.dart @@ -0,0 +1,14 @@ +import 'json_helpers.dart'; + +class PlaybackHint { + final int mediaId; + final double positionSeconds; + final bool finished; + final DateTime? updatedAt; + + const PlaybackHint({required this.mediaId, required this.positionSeconds, required this.finished, this.updatedAt}); + + factory PlaybackHint.fromJson(Map<String, dynamic> json) => PlaybackHint(mediaId: json['media_id'] as int? ?? 0, positionSeconds: (json['position_seconds'] as num?)?.toDouble() ?? 0, finished: json['finished'] as bool? ?? false, updatedAt: dateTimeFromJson(json['updated_at'])); + + Map<String, dynamic> toJson() => {'media_id': mediaId, 'position_seconds': positionSeconds, 'finished': finished, 'updated_at': dateTimeToJson(updatedAt)}; +} diff --git a/player-android/lib/models/podcast_episode.dart b/player-android/lib/models/podcast_episode.dart new file mode 100644 index 0000000..5720e5f --- /dev/null +++ b/player-android/lib/models/podcast_episode.dart @@ -0,0 +1,17 @@ +import 'json_helpers.dart'; + +class PodcastEpisode { + final int id, feedId; + final int? mediaId, fileSize; + final String guid, title, description, episodeUrl, fileName; + final double? durationSeconds; + final bool isDownloaded, isCompleted; + final double positionSeconds; + final DateTime? publishedAt, createdAt; + + const PodcastEpisode({required this.id, required this.feedId, this.mediaId, required this.guid, required this.title, required this.description, this.publishedAt, required this.episodeUrl, this.durationSeconds, this.fileSize, required this.fileName, required this.isDownloaded, this.isCompleted = false, this.positionSeconds = 0, this.createdAt}); + + factory PodcastEpisode.fromJson(Map<String, dynamic> json) => PodcastEpisode(id: json['id'] as int? ?? 0, feedId: json['feed_id'] as int? ?? 0, mediaId: json['media_id'] as int?, guid: json['guid'] as String? ?? '', title: json['title'] as String? ?? '', description: json['description'] as String? ?? '', publishedAt: dateTimeFromJson(json['published_at']), episodeUrl: json['episode_url'] as String? ?? '', durationSeconds: (json['duration_seconds'] as num?)?.toDouble(), fileSize: json['file_size'] as int?, fileName: json['file_name'] as String? ?? '', isDownloaded: json['is_downloaded'] as bool? ?? false, isCompleted: json['is_completed'] as bool? ?? false, positionSeconds: (json['position_seconds'] as num?)?.toDouble() ?? 0, createdAt: dateTimeFromJson(json['created_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'feed_id': feedId, 'media_id': mediaId, 'guid': guid, 'title': title, 'description': description, 'published_at': dateTimeToJson(publishedAt), 'episode_url': episodeUrl, 'duration_seconds': durationSeconds, 'file_size': fileSize, 'file_name': fileName, 'is_downloaded': isDownloaded, 'is_completed': isCompleted, 'position_seconds': positionSeconds, 'created_at': dateTimeToJson(createdAt)}; +} diff --git a/player-android/lib/models/podcast_feed.dart b/player-android/lib/models/podcast_feed.dart new file mode 100644 index 0000000..641b6db --- /dev/null +++ b/player-android/lib/models/podcast_feed.dart @@ -0,0 +1,14 @@ +import 'json_helpers.dart'; + +class PodcastFeed { + final int id, setId, checkIntervalMinutes, consecutiveFailures; + final String feedUrl, title, description, imageUrl, lastETag; + final bool autoDownload; + final DateTime? lastCheckedAt, nextCheckAt, createdAt; + + const PodcastFeed({required this.id, required this.setId, required this.feedUrl, required this.title, required this.description, required this.imageUrl, this.lastCheckedAt, required this.lastETag, required this.checkIntervalMinutes, required this.autoDownload, required this.consecutiveFailures, this.nextCheckAt, this.createdAt}); + + factory PodcastFeed.fromJson(Map<String, dynamic> json) => PodcastFeed(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, feedUrl: json['feed_url'] as String? ?? '', title: json['title'] as String? ?? '', description: json['description'] as String? ?? '', imageUrl: json['image_url'] as String? ?? '', lastCheckedAt: dateTimeFromJson(json['last_checked_at']), lastETag: json['last_etag'] as String? ?? '', checkIntervalMinutes: json['check_interval_minutes'] as int? ?? 0, autoDownload: json['auto_download'] as bool? ?? false, consecutiveFailures: json['consecutive_failures'] as int? ?? 0, nextCheckAt: dateTimeFromJson(json['next_check_at']), createdAt: dateTimeFromJson(json['created_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'set_id': setId, 'feed_url': feedUrl, 'title': title, 'description': description, 'image_url': imageUrl, 'last_checked_at': dateTimeToJson(lastCheckedAt), 'last_etag': lastETag, 'check_interval_minutes': checkIntervalMinutes, 'auto_download': autoDownload, 'consecutive_failures': consecutiveFailures, 'next_check_at': dateTimeToJson(nextCheckAt), 'created_at': dateTimeToJson(createdAt)}; +} diff --git a/player-android/lib/models/share.dart b/player-android/lib/models/share.dart new file mode 100644 index 0000000..210ac90 --- /dev/null +++ b/player-android/lib/models/share.dart @@ -0,0 +1,17 @@ +import 'json_helpers.dart'; + +class Share { + final String token; + final int mediaId; + final int createdBy; + final DateTime? createdAt; + final DateTime? expiresAt; + final int? maxUses; + final int usedCount; + + const Share({required this.token, required this.mediaId, required this.createdBy, this.createdAt, this.expiresAt, this.maxUses, required this.usedCount}); + + factory Share.fromJson(Map<String, dynamic> json) => Share(token: json['token'] as String? ?? '', mediaId: json['media_id'] as int? ?? 0, createdBy: json['created_by'] as int? ?? 0, createdAt: dateTimeFromJson(json['created_at']), expiresAt: dateTimeFromJson(json['expires_at']), maxUses: json['max_uses'] as int?, usedCount: json['used_count'] as int? ?? 0); + + Map<String, dynamic> toJson() => {'token': token, 'media_id': mediaId, 'created_by': createdBy, 'created_at': dateTimeToJson(createdAt), 'expires_at': dateTimeToJson(expiresAt), 'max_uses': maxUses, 'used_count': usedCount}; +} diff --git a/player-android/lib/models/tag.dart b/player-android/lib/models/tag.dart new file mode 100644 index 0000000..ed03406 --- /dev/null +++ b/player-android/lib/models/tag.dart @@ -0,0 +1,11 @@ +class Tag { + final int id; + final String name; + + const Tag({required this.id, required this.name}); + + factory Tag.fromJson(Map<String, dynamic> json) => + Tag(id: json['id'] as int? ?? 0, name: json['name'] as String? ?? ''); + + Map<String, dynamic> toJson() => {'id': id, 'name': name}; +} diff --git a/player-android/lib/models/user.dart b/player-android/lib/models/user.dart new file mode 100644 index 0000000..a75832f --- /dev/null +++ b/player-android/lib/models/user.dart @@ -0,0 +1,14 @@ +import 'json_helpers.dart'; + +class User { + final int id; + final String username; + final bool isAdmin; + final DateTime? createdAt; + + const User({required this.id, required this.username, required this.isAdmin, this.createdAt}); + + factory User.fromJson(Map<String, dynamic> json) => User(id: json['id'] as int? ?? 0, username: json['username'] as String? ?? '', isAdmin: json['is_admin'] as bool? ?? false, createdAt: dateTimeFromJson(json['created_at'])); + + Map<String, dynamic> toJson() => {'id': id, 'username': username, 'is_admin': isAdmin, 'created_at': dateTimeToJson(createdAt)}; +} diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml new file mode 100644 index 0000000..cd4d900 --- /dev/null +++ b/player-android/pubspec.yaml @@ -0,0 +1,18 @@ +name: player_android +description: Player Android client +publish_to: "none" +version: 0.1.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true |
