From 68eedee48c732d596b46a502ae98963beaa494ad Mon Sep 17 00:00:00 2001 From: Dan Pilch Date: Mon, 9 Mar 2026 10:32:58 +0000 Subject: [PATCH 1/3] add category management with enable/disable, search, and select all Add enabled column to groups table (migration 4), filter channels by enabled categories, and add a Categories section in settings with checkboxes, select all toggle, and search bar. --- lib/backend/db_factory.dart | 9 +++ lib/backend/sql.dart | 59 +++++++++++++++-- lib/home.dart | 5 ++ lib/models/filters.dart | 2 + lib/settings_view.dart | 129 ++++++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 4 deletions(-) diff --git a/lib/backend/db_factory.dart b/lib/backend/db_factory.dart index 450dab3..133c951 100644 --- a/lib/backend/db_factory.dart +++ b/lib/backend/db_factory.dart @@ -121,6 +121,15 @@ class DbFactory { await tx.execute(''' CREATE INDEX index_groups_media_type ON groups(media_type); '''); + })) + ..add(SqliteMigration(4, (tx) async { + await tx.execute(''' + ALTER TABLE groups + ADD COLUMN enabled integer DEFAULT 1; + '''); + await tx.execute(''' + CREATE INDEX index_groups_enabled ON groups(enabled); + '''); })); await migrations.migrate(db); return db; diff --git a/lib/backend/sql.dart b/lib/backend/sql.dart index 8c00a25..2575e83 100644 --- a/lib/backend/sql.dart +++ b/lib/backend/sql.dart @@ -158,13 +158,17 @@ class Sql { ? query.split(" ").map((f) => "%$f%").toList() : ["%$query%"]; var sqlQuery = ''' - SELECT * FROM channels + SELECT * FROM channels WHERE (${getKeywordsSql(keywords.length)}) AND media_type IN (${generatePlaceholders(mediaTypes.length)}) AND source_id IN (${generatePlaceholders(filters.sourceIds!.length)}) AND url IS NOT NULL '''; List params = []; + if (filters.enabledGroupIds != null && filters.enabledGroupIds!.isNotEmpty) { + sqlQuery += + "\nAND (group_id IS NULL OR group_id IN (${generatePlaceholders(filters.enabledGroupIds!.length)}))"; + } if (filters.viewType == ViewType.favorites && filters.seriesId == null) { sqlQuery += "\nAND favorite = 1"; } @@ -181,6 +185,9 @@ class Sql { params.addAll(keywords); params.addAll(mediaTypes); params.addAll(filters.sourceIds!); + if (filters.enabledGroupIds != null && filters.enabledGroupIds!.isNotEmpty) { + params.addAll(filters.enabledGroupIds!); + } if (filters.seriesId != null) { params.add(filters.seriesId!); } else if (filters.groupId != null) { @@ -224,10 +231,11 @@ class Sql { : ["%$query%"]; var mediaTypes = filters.mediaTypes!.map((x) => x.index); var sqlQuery = ''' - SELECT * FROM groups + SELECT * FROM groups WHERE (${getKeywordsSql(keywords.length)}) AND (media_type IS NULL OR media_type IN (${generatePlaceholders(mediaTypes.length)})) AND source_id IN (${generatePlaceholders(filters.sourceIds!.length)}) + AND enabled = 1 LIMIT ?, ? '''; List params = []; @@ -375,12 +383,55 @@ class Sql { static Future setSourceEnabled(bool enabled, int sourceId) async { var db = await DbFactory.db; await db.execute(''' - UPDATE sources - SET enabled = ? + UPDATE sources + SET enabled = ? WHERE id = ? ''', [enabled, sourceId]); } + static Future setGroupEnabled(bool enabled, int groupId) async { + var db = await DbFactory.db; + await db.execute(''' + UPDATE groups + SET enabled = ? + WHERE id = ? + ''', [enabled ? 1 : 0, groupId]); + } + + static Future setAllGroupsEnabled(bool enabled) async { + var db = await DbFactory.db; + await db.execute(''' + UPDATE groups + SET enabled = ? + ''', [enabled ? 1 : 0]); + } + + static Future>> getGroups() async { + var db = await DbFactory.db; + var results = await db.getAll(''' + SELECT g.id, g.name, g.enabled, s.name as source_name + FROM groups g + JOIN sources s ON g.source_id = s.id + ORDER BY s.name, g.name + '''); + return results + .map((row) => { + 'id': row.columnAt(0) as int, + 'name': row.columnAt(1) as String, + 'enabled': row.columnAt(2) == 1, + 'sourceName': row.columnAt(3) as String, + }) + .toList(); + } + + static Future> getEnabledGroupIds() async { + var db = await DbFactory.db; + var results = await db.getAll(''' + SELECT id FROM groups WHERE enabled = 1 + '''); + return results.map((row) => row.columnAt(0) as int).toList(); + } + static Future setPosition(int channelId, int seconds) async { var db = await DbFactory.db; await db.execute(''' diff --git a/lib/home.dart b/lib/home.dart index 746aa3a..62dcfb9 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -61,6 +61,9 @@ class _HomeState extends State { widget.home.filters.mediaTypes = (await SettingsService.getSettings()) .getMediaTypes(); } + if (widget.home.filters.enabledGroupIds == null) { + widget.home.filters.enabledGroupIds = await Sql.getEnabledGroupIds(); + } await load(); final String? version = await SettingsService.shouldShowWhatsNew(); if (widget.firstLaunch && version != null) { @@ -169,6 +172,7 @@ class _HomeState extends State { viewType: type, mediaTypes: widget.home.filters.mediaTypes, sourceIds: widget.home.filters.sourceIds, + enabledGroupIds: widget.home.filters.enabledGroupIds, ), ), ), @@ -184,6 +188,7 @@ class _HomeState extends State { viewType: ViewType.all, mediaTypes: widget.home.filters.mediaTypes, sourceIds: widget.home.filters.sourceIds, + enabledGroupIds: widget.home.filters.enabledGroupIds, ), ); if (widget.home.filters.groupId != null) { diff --git a/lib/models/filters.dart b/lib/models/filters.dart index fa6f8be..cdbf75b 100644 --- a/lib/models/filters.dart +++ b/lib/models/filters.dart @@ -9,6 +9,7 @@ class Filters { int page; int? seriesId; int? groupId; + List? enabledGroupIds; bool useKeywords; Filters({ @@ -19,6 +20,7 @@ class Filters { this.page = 1, this.seriesId, this.groupId, + this.enabledGroupIds, this.useKeywords = false, }); } diff --git a/lib/settings_view.dart b/lib/settings_view.dart index a8683bf..ae2b6f5 100644 --- a/lib/settings_view.dart +++ b/lib/settings_view.dart @@ -31,6 +31,8 @@ class SettingsView extends StatefulWidget { class _SettingsState extends State { Settings settings = Settings(); List sources = []; + List> categories = []; + String _categorySearch = ''; bool loading = true; @override void initState() { @@ -42,10 +44,12 @@ class _SettingsState extends State { var results = await Future.wait([ SettingsService.getSettings(), Sql.getSources(), + Sql.getGroups(), ]); setState(() { settings = results[0] as Settings; sources = results[1] as List; + categories = results[2] as List>; loading = false; }); } @@ -191,6 +195,79 @@ class _SettingsState extends State { ); } + Future toggleCategory(Map category) async { + await Error.tryAsyncNoLoading( + () async => + await Sql.setGroupEnabled(!(category['enabled'] as bool), category['id'] as int), + context, + ); + await reloadCategories(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text("Category ${!(category['enabled'] as bool) ? "enabled" : "disabled"}"), + duration: const Duration(milliseconds: 500), + ), + ); + } + + Future setAllCategoriesEnabled(bool enabled) async { + await Error.tryAsyncNoLoading( + () async => await Sql.setAllGroupsEnabled(enabled), + context, + ); + await reloadCategories(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("All categories ${enabled ? "enabled" : "disabled"}"), + duration: const Duration(milliseconds: 500), + ), + ); + } + + Future reloadCategories() async { + await Error.tryAsyncNoLoading( + () async => categories = await Sql.getGroups(), + context, + ); + setState(() { + categories; + }); + } + + List> get filteredCategories { + if (_categorySearch.isEmpty) return categories; + final query = _categorySearch.toLowerCase(); + return categories + .where((c) => + (c['name'] as String).toLowerCase().contains(query) || + (c['sourceName'] as String).toLowerCase().contains(query)) + .toList(); + } + + Widget getCategory(Map category) { + final enabled = category['enabled'] as bool; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + elevation: 5, + child: ListTile( + leading: Checkbox( + value: enabled, + onChanged: (_) => toggleCategory(category), + ), + onTap: () => toggleCategory(category), + contentPadding: const EdgeInsets.only(left: 10), + title: Text(category['name'] as String), + subtitle: Text(category['sourceName'] as String), + ), + ); + } + Future reloadSources() async { await Error.tryAsyncNoLoading( () async => sources = await Sql.getSources(), @@ -373,6 +450,58 @@ class _SettingsState extends State { ), const SizedBox(height: 10), ...sources.map(getSource), + if (categories.isNotEmpty) ...[ + const Divider(), + const Padding( + padding: EdgeInsets.only(left: 10), + child: Text( + 'Categories', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + Checkbox( + value: categories.every((c) => c['enabled'] as bool), + tristate: true, + onChanged: (value) { + final allEnabled = categories.every((c) => c['enabled'] as bool); + setAllCategoriesEnabled(!allEnabled); + }, + ), + GestureDetector( + onTap: () { + final allEnabled = categories.every((c) => c['enabled'] as bool); + setAllCategoriesEnabled(!allEnabled); + }, + child: const Text('Select All'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: TextField( + decoration: const InputDecoration( + hintText: 'Search categories...', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + onChanged: (value) { + setState(() { + _categorySearch = value; + }); + }, + ), + ), + const SizedBox(height: 10), + ...filteredCategories.map(getCategory), + ], ], ), ), From 2edc3dfeb48ca0c54aca368d29546e70c40cd84b Mon Sep 17 00:00:00 2001 From: Dan Pilch Date: Mon, 9 Mar 2026 14:53:33 +0000 Subject: [PATCH 2/3] fix android tv mode category selection and pause button triggers adding channel to favourites --- lib/channel_tile.dart | 19 ++++++++++++------- lib/settings_view.dart | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/lib/channel_tile.dart b/lib/channel_tile.dart index d5cf28a..3e4a3a7 100644 --- a/lib/channel_tile.dart +++ b/lib/channel_tile.dart @@ -34,14 +34,19 @@ class _ChannelTileState extends State { void initState() { super.initState(); _focusNode.onKeyEvent = (node, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.arrowRight) { - if (!FocusScope.of( - context, - ).focusInDirection(TraversalDirection.right)) { - widget.onFocusNavbar?.call(); + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (!FocusScope.of( + context, + ).focusInDirection(TraversalDirection.right)) { + widget.onFocusNavbar?.call(); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.mediaPlayPause) { + favorite(); + return KeyEventResult.handled; } - return KeyEventResult.handled; } return KeyEventResult.ignored; }; diff --git a/lib/settings_view.dart b/lib/settings_view.dart index ae2b6f5..a6d3127 100644 --- a/lib/settings_view.dart +++ b/lib/settings_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:open_tv/backend/settings_service.dart'; import 'package:open_tv/backend/sql.dart'; import 'package:open_tv/backend/utils.dart'; @@ -34,12 +35,52 @@ class _SettingsState extends State { List> categories = []; String _categorySearch = ''; bool loading = true; + bool _searchReadOnly = true; + late final FocusNode _searchFocusNode; @override void initState() { super.initState(); + _searchFocusNode = FocusNode( + onKeyEvent: (node, event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + setState(() => _searchReadOnly = true); + node.nextFocus(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + setState(() => _searchReadOnly = true); + node.previousFocus(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.select) { + if (_searchReadOnly) { + setState(() => _searchReadOnly = false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.goBack) { + setState(() => _searchReadOnly = true); + node.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); initAsync(); } + @override + void dispose() { + _searchFocusNode.dispose(); + super.dispose(); + } + Future initAsync() async { var results = await Future.wait([ SettingsService.getSettings(), @@ -487,6 +528,8 @@ class _SettingsState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: TextField( + focusNode: _searchFocusNode, + readOnly: _searchReadOnly, decoration: const InputDecoration( hintText: 'Search categories...', prefixIcon: Icon(Icons.search), From 6563fba73adf287e47d8bbf25e9ea8d647ace5e4 Mon Sep 17 00:00:00 2001 From: Dan Pilch Date: Tue, 10 Mar 2026 09:43:00 +0000 Subject: [PATCH 3/3] fix tapping category search --- lib/settings_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/settings_view.dart b/lib/settings_view.dart index a6d3127..a5026fa 100644 --- a/lib/settings_view.dart +++ b/lib/settings_view.dart @@ -529,7 +529,7 @@ class _SettingsState extends State { padding: const EdgeInsets.symmetric(horizontal: 10), child: TextField( focusNode: _searchFocusNode, - readOnly: _searchReadOnly, + readOnly: !widget.showNavBar && _searchReadOnly, decoration: const InputDecoration( hintText: 'Search categories...', prefixIcon: Icon(Icons.search),